diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md index 059062e7..e42953be 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md @@ -8,9 +8,10 @@ The following mock server definitions are included out of the box: | Server | File | Description | |--------|------|-------------| -| `mcp_CalendarTools` | `mocks/mcp_CalendarTools.json` | Calendar operations (createEvent, listEvents, getSchedule, findMeetingTimes, etc.) | -| `mcp_MailTools` | `mocks/mcp_MailTools.json` | Email operations (SendEmail, SendEmailWithAttachments, etc.) | -| `mcp_MeServer` | `mocks/mcp_MeServer.json` | User/directory operations (listUsers, getMyProfile, getManager, etc.) | +| `mcp_CalendarTools` | `mocks/mcp_CalendarTools.json` | Calendar operations (ListEvents, CreateEvent, FindMeetingTimes, AcceptEvent, etc.) | +| `mcp_MailTools` | `mocks/mcp_MailTools.json` | Email operations (SendEmailWithAttachments, SearchMessages, FlagEmail, ReplyToMessage, etc.) | +| `mcp_MeServer` | `mocks/mcp_MeServer.json` | User/directory operations (GetMyDetails, GetUserDetails, GetManagerDetails, etc.) | +| `mcp_KnowledgeTools` | `mocks/mcp_KnowledgeTools.json` | Federated knowledge operations (configure_federated_knowledge, query_federated_knowledge, etc.) | ### mcp_MeServer Tools @@ -34,6 +35,71 @@ Tools for email operations including `SendEmail`, `SendEmailWithAttachments`, an --- +## Fidelity Contract + +### What the mock guarantees + +Every tool exposed by a real M365 MCP server is present in the corresponding mock with the same name, same casing, and same required input fields. This ensures that agents developed against the mock will not encounter missing-tool or schema-mismatch errors when switched to a real server. + +### What the mock does not guarantee + +The mock does **not** provide real data, real authentication, or real side effects. Responses are rendered from templates and are not backed by Microsoft Graph or any live service. + +### Snapshot-based verification + +The `snapshots/` directory contains authoritative tool catalogs captured from real M365 MCP servers. Each snapshot file records the tool names, descriptions, and input schemas as they exist on the real server at the time of capture. + +To verify that mock definitions match the real server contracts locally: + +```bash +dotnet test --filter "FullyQualifiedName~MockToolFidelityTests" +``` + +To refresh snapshots when real servers change (requires M365 credentials): + +```bash +$env:MCP_BEARER_TOKEN = a365 develop get-token --output raw +MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +``` + +--- + +## Keeping Mocks Current + +### When to update snapshots + +- When real M365 MCP servers add, rename, or remove tools +- Before a release, to confirm mocks still match production +- When agent tests pass locally against mocks but fail against a real environment + +### How to update + +1. Obtain a bearer token using the CLI: + + ```pwsh + # With an agent project present: + $env:MCP_BEARER_TOKEN = a365 develop get-token --output raw + + # Without an agent project — pass app ID and explicit scopes: + $env:MCP_BEARER_TOKEN = a365 develop get-token --app-id --scopes McpServers.Mail.All McpServers.Calendar.All McpServers.Me.All McpServers.Knowledge.All --output raw + ``` + +2. Run the snapshot capture tests to write updated snapshot files: + + ```bash + MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" + ``` + +3. After updating snapshots, update the corresponding mock JSON files in `mocks/` to match any new or changed tools. + +4. Run fidelity tests to confirm coverage: + + ```bash + dotnet test --filter "FullyQualifiedName~MockToolFidelityTests" + ``` + +--- + # How to mock notifications for custom activities ## Prerequisites diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/design.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/design.md index 9ee4b30f..0617fac8 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/design.md +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/design.md @@ -35,6 +35,11 @@ flowchart TB subgraph Storage["File Storage"] MocksDir["mocks/
*.json files"] + SnapshotsDir["snapshots/
Authoritative tool catalogs
from real M365 servers"] + end + + subgraph Testing["Fidelity Testing"] + FidelityTests["MockToolFidelityTests
Asserts mock coverage
against snapshots"] end subgraph Endpoints["HTTP Endpoints"] @@ -51,6 +56,8 @@ flowchart TB ServerClass --> MockToolExecutor MockToolExecutor --> IMockToolStore ServerClass --> Endpoints + FidelityTests --> SnapshotsDir + FidelityTests --> MocksDir ``` --- @@ -202,6 +209,37 @@ The `FileMockToolStore` uses `FileSystemWatcher` to detect changes to mock defin --- +## Fidelity Contract + +The mock server maintains a contract with the real M365 MCP servers through a two-layer design. + +### Two-layer design + +- **`mocks/`** (behavior layer) — Contains mock tool definitions including response templates, simulated delay, and error simulation configuration. This is what the mock server loads at runtime. +- **`snapshots/`** (contract layer) — Contains authoritative tool catalogs captured from real M365 MCP servers. Each snapshot records the exact tool names, descriptions, and input schemas as they exist on the real server. Snapshots are the source of truth for what the mock must cover. + +### CI enforcement + +`MockToolFidelityTests` loads each snapshot file alongside the corresponding mock definition file and asserts that: + +- Every tool present in the snapshot exists in the mock (same name, same casing) +- Every enabled tool in the mock exists in the snapshot (no phantom tools with unverified names) + +These tests run as part of the standard test suite. Snapshots where `capturedAt` is `"UNPOPULATED"` are skipped — they do not block CI until real data has been captured. + +### The `capturedAt` freshness indicator + +Each snapshot file includes a `capturedAt` field set to an ISO 8601 UTC timestamp at time of capture. The value `"UNPOPULATED"` indicates the file is a placeholder that has never been verified against a real server. Fidelity tests skip for UNPOPULATED snapshots. + +### Update process + +1. **Detect drift** — Run `MockToolSnapshotCaptureTests` with `MCP_BEARER_TOKEN` set to query live M365 MCP servers and compare against snapshots. Tests fail with a clear diff if the real server has changed. +2. **Refresh snapshots** — Re-run with `MCP_UPDATE_SNAPSHOTS=true` to write updated snapshot files to disk. +3. **Update mocks** — Add, rename, or remove tools in the corresponding `mocks/` JSON files to match the refreshed snapshots. +4. **Verify** — Run `MockToolFidelityTests` (no credentials required) to confirm all mock files satisfy their snapshot contracts. + +--- + ## Usage ### Starting the Server diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json index 8794eda6..67005904 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json @@ -1,82 +1,104 @@ [ { - "name": "acceptEvent", - "description": "Accept the specified event invitation in a user's calendar.", + "name": "ListEvents", + "description": "Retrieve a list of events in a user's calendar.For recurring meetings, only return one / first record with full recurrence details (pattern, start, end) to the agent.For searching by meeting title, filter using contains(subject,'X'); avoid 'eq' or startswith(subject,'X') filter for this case.Use this tool to find existing meetings whenever the user refers to a meeting by day, date, time , or title (e.g., \"add someone to the architecture review at 2 PM\"), before calling any tool that modifies, updates, or cancels a meeting.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The ID or userPrincipalName of the user.", + "description": "If no organizer is specified, use current user. If organizer is explicitly mentioned - retrieve their user principal name and use that value.", "x-ms-location": "path", "x-ms-path": "userId" }, - "eventId": { + "startDateTime": { "type": "string", - "description": "The ID of the event to accept.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The start of the time range for the events (ISO 8601 format). Should be today / after today.", + "x-ms-location": "query", + "x-ms-path": "startDateTime" }, - "comment": { + "endDateTime": { "type": "string", - "description": "Optional text included in the response.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "The end of the time range for the events (ISO 8601 format). Should be after the startTime", + "x-ms-location": "query", + "x-ms-path": "endDateTime" }, - "sendResponse": { - "type": "boolean", - "description": "Whether to send a response to the organizer.", - "x-ms-location": "body", - "x-ms-path": "sendResponse" + "top": { + "type": "integer", + "description": "The maximum number of events to return.", + "x-ms-location": "query", + "x-ms-path": "$top" + }, + "filter": { + "type": "string", + "description": "OData filter query to filter events.Filter by the date, time, day if supplied in the input prompt.", + "x-ms-location": "query", + "x-ms-path": "$filter" + }, + "orderby": { + "type": "string", + "description": "OData order by query to sort events.", + "x-ms-location": "query", + "x-ms-path": "$orderby" } - }, - "required": [ - "eventId" - ] + } }, - "responseTemplate": "Event '{{eventId}}' accepted (mock).", + "responseTemplate": "{\"value\": [{\"id\": \"mock-event-001\", \"subject\": \"Team Sync\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T10:30:00\"}}, {\"id\": \"mock-event-002\", \"subject\": \"Product Roadmap Review\", \"start\": {\"dateTime\": \"2026-02-01T14:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T15:00:00\"}}, {\"id\": \"mock-event-003\", \"subject\": \"Board Presentation\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T11:00:00\"}}]}", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "cancelEvent", - "description": "Cancel an event in a specified user's calendar and notify attendees.", + "name": "ListCalendarView", + "description": "Retrieve events from a user's calendar view. Use this tool whenever you need to retrieve one meeting instance of a recurrening event(not master series) occurring in a window (e.g., 'tomorrow morning' or 'between 2 PM and 4 PM') before calling any tool that modifies, updates, or cancels a meeting.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The ID or userPrincipalName of the user who owns the event.", + "description": "", "x-ms-location": "path", "x-ms-path": "userId" }, - "eventId": { + "startDateTime": { "type": "string", - "description": "The unique identifier of the event to cancel.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "Start of the time range (ISO 8601). Should be today / after today.", + "x-ms-location": "query", + "x-ms-path": "startDateTime" }, - "comment": { + "endDateTime": { "type": "string", - "description": "Optional message to include in the cancellation notification to attendees.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "End of the time range (ISO 8601).should be after startDateTime.", + "x-ms-location": "query", + "x-ms-path": "endDateTime" + }, + "top": { + "type": "integer", + "description": "Max number of events to return.", + "x-ms-location": "query", + "x-ms-path": "$top" + }, + "orderby": { + "type": "string", + "description": "Order by clause (e.g. start/dateTime).", + "x-ms-location": "query", + "x-ms-path": "$orderby" } }, "required": [ - "eventId" + "startDateTime", + "endDateTime" ] }, - "responseTemplate": "Event '{{eventId}}' cancelled (mock).", + "responseTemplate": "{\"value\": [{\"id\": \"mock-event-001\", \"subject\": \"Team Sync\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T10:30:00\"}, \"showAs\": \"busy\"}, {\"id\": \"mock-event-002\", \"subject\": \"Product Roadmap Review\", \"start\": {\"dateTime\": \"2026-02-01T14:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T15:00:00\"}, \"showAs\": \"busy\"}, {\"id\": \"mock-event-003\", \"subject\": \"Board Presentation\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T11:00:00\"}, \"showAs\": \"busy\"}]}", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "createEvent", + "name": "CreateEvent", "description": "\"Use this to create a new event in current user's calendar. IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses..If time is provided, schedule the event — even if there are conflicts/ even if attendee/organizer is busy in that slot.If only a date is given, use the earliest slot where all or most attendees (> 50% of them) are free. If no date is given, schedule meeting at given time for today.If no time/date is given, use the first slot where all attendees are free.Try to create events during working hours of signed-in user (8 AM – 5 PM) if explicit time is not specified.Specify recurrence using recurrence property.Online meetings can be created by setting isOnlineMeeting to true.Default meeting duration is 30 minutes, if not specified by user.\"", "inputSchema": { "type": "object", @@ -358,90 +380,110 @@ "enabled": true }, { - "name": "declineEvent", - "description": "Decline the specified event invitation in a user's calendar.", + "name": "UpdateEvent", + "description": "Update an existing calendar event in a specified user's calendar. Address of the attendees should be a valid email address.IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The ID or userPrincipalName of the user.", + "description": "The ID or userPrincipalName of the user whose event is being updated.", "x-ms-location": "path", "x-ms-path": "userId" }, "eventId": { "type": "string", - "description": "The ID of the event to decline.", + "description": "The unique identifier of the event to update.", "x-ms-location": "path", "x-ms-path": "eventId" }, - "comment": { + "subject": { "type": "string", - "description": "Optional text included in the response.", + "description": "The updated subject of the event.", "x-ms-location": "body", - "x-ms-path": "comment" + "x-ms-path": "subject" }, - "sendResponse": { - "type": "boolean", - "description": "Whether to send a response to the organizer.", + "body": { + "type": "object", + "description": "The updated body content of the event.", "x-ms-location": "body", - "x-ms-path": "sendResponse" - } - }, - "required": [ - "eventId" - ] - }, - "responseTemplate": "Event '{{eventId}}' declined (mock).", - "delayMs": 250, - "errorRate": 0, - "statusCode": 200, - "enabled": true - }, - { - "name": "deleteEvent", - "description": "Delete an event from a specified user's calendar.", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "The ID or userPrincipalName of the user whose event is being deleted.", - "x-ms-location": "path", - "x-ms-path": "userId" + "x-ms-path": "body", + "properties": { + "contentType": { + "type": "string", + "enum": [ + "Text", + "HTML" + ], + "description": "The content type of the body.", + "x-ms-location": "body", + "x-ms-path": "body.contentType" + }, + "content": { + "type": "string", + "description": "The body content.", + "x-ms-location": "body", + "x-ms-path": "body.content" + } + } }, - "eventId": { - "type": "string", - "description": "The unique identifier of the event to delete.", - "x-ms-location": "path", - "x-ms-path": "eventId" - } - }, - "required": [ - "eventId" - ] - }, - "responseTemplate": "Event '{{eventId}}' deleted (mock).", - "delayMs": 250, - "errorRate": 0, - "statusCode": 200, - "enabled": true - }, - { - "name": "findMeetingTimes", - "description": "Suggest possible meeting times and locations based on organizer and attendee availability. Use only when the user requests to find an open time slot for a new meeting (e.g., 'schedule a meeting', 'find a slot', 'when can we meet'). Do not use this tool for locating or updating existing meetings.", - "inputSchema": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "The ID or userPrincipalName of the organizer.", - "x-ms-location": "path", - "x-ms-path": "userId" + "start": { + "type": "object", + "description": "Updated start time of the event.", + "x-ms-location": "body", + "x-ms-path": "start", + "properties": { + "dateTime": { + "type": "string", + "description": "Start date and time in ISO format.", + "x-ms-location": "body", + "x-ms-path": "start.dateTime" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the start time.Use the system timezone if not explictly specified.", + "x-ms-location": "body", + "x-ms-path": "start.timeZone" + } + } + }, + "end": { + "type": "object", + "description": "Updated end time of the event.", + "x-ms-location": "body", + "x-ms-path": "end", + "properties": { + "dateTime": { + "type": "string", + "description": "End date and time in ISO format.", + "x-ms-location": "body", + "x-ms-path": "end.dateTime" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the end time. Should be same as the start timezone.", + "x-ms-location": "body", + "x-ms-path": "end.timeZone" + } + } + }, + "location": { + "type": "object", + "description": "Updated location of the event.", + "x-ms-location": "body", + "x-ms-path": "location", + "properties": { + "displayName": { + "type": "string", + "description": "Display name of the location.", + "x-ms-location": "body", + "x-ms-path": "location.displayName" + } + } }, "attendees_addresses": { "type": "array", - "description": "Email addresses of attendees for meeting time suggestions. Each must be a valid email address.", + "description": "Email addresses of updated attendees. Each must be a valid email address.", "x-ms-location": "body", "x-ms-path": "attendees", "x-ms-restructure": "attendees", @@ -476,196 +518,213 @@ "type": "string" } }, - "timeConstraint": { - "type": "object", - "description": "Time availability (timeslots, activityDomain).", - "x-ms-location": "body", - "x-ms-path": "timeConstraint", - "properties": { - "timeSlots": { - "type": "array", - "items": { - "type": "object", - "properties": { - "start": { - "type": "object", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] - }, - "end": { - "type": "object", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] - } - } - } - }, - "activityDomain": { - "type": "string", - "enum": [ - "work", - "unrestricted" - ] - } - } - }, - "meetingDuration": { - "type": "string", - "description": "Meeting duration (e.g. 'PT1H').", + "isCancelled": { + "type": "boolean", + "description": "Set to true to cancel the event.", "x-ms-location": "body", - "x-ms-path": "meetingDuration" + "x-ms-path": "isCancelled" }, - "locationConstraint": { + "recurrence": { "type": "object", - "description": "Options for meeting location.", + "description": "Defines the recurrence pattern and range for the event.Default duration is 6 months.", "x-ms-location": "body", - "x-ms-path": "locationConstraint", + "x-ms-path": "recurrence", "properties": { - "isRequired": { - "type": "boolean" - }, - "suggestLocation": { - "type": "boolean" - }, - "locations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "displayName": { - "type": "string" - }, - "locationEmailAddress": { - "type": "string" + "pattern": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "daily", + "weekly", + "absoluteMonthly", + "relativeMonthly", + "absoluteYearly", + "relativeYearly" + ], + "description": "The recurrence pattern type." + }, + "interval": { + "type": "integer", + "description": "The interval between occurrences." + }, + "daysOfWeek": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday" + ] }, - "resolveAvailability": { - "type": "boolean" - } + "description": "The days of the week for the recurrence." + }, + "dayOfMonth": { + "type": "integer", + "description": "The day of the month for the recurrence." + }, + "month": { + "type": "integer", + "description": "The month for the yearly pattern." + } + } + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "endDate", + "noEnd", + "numbered" + ], + "description": "The recurrence range type. Default duration is 6 months." + }, + "startDate": { + "type": "string", + "description": "The date to start the recurrence (yyyy-mm-dd). Should be today / after today." + }, + "endDate": { + "type": "string", + "description": "The date to end the recurrence (yyyy-mm-dd). should be after startDate" }, - "required": [ - "displayName" - ] + "numberOfOccurrences": { + "type": "integer", + "description": "The number of times to repeat." + } } } } - }, - "maxCandidates": { - "type": "integer" - }, - "isOrganizerOptional": { - "type": "boolean" - }, - "returnSuggestionReasons": { - "type": "boolean" - }, - "minimumAttendeePercentage": { - "type": "number", - "format": "double" } }, "required": [ - "meetingDuration" + "eventId" ] }, - "responseTemplate": "Meeting time suggestions found for duration {{meetingDuration}} (mock).", + "responseTemplate": "Event '{{eventId}}' updated (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "getEvent", - "description": "Get a single calendar event from a specified user’s calendar.", + "name": "DeleteEventById", + "description": "Delete an event from a specified user's calendar.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The ID or userPrincipalName of the user whose event is being retrieved.", + "description": "The ID or userPrincipalName of the user whose event is being deleted.", "x-ms-location": "path", "x-ms-path": "userId" }, "eventId": { "type": "string", - "description": "The unique identifier of the event.", + "description": "The unique identifier of the event to delete.", "x-ms-location": "path", "x-ms-path": "eventId" - }, - "select": { - "type": "string", - "description": "OData $select query parameter to specify returned properties.", - "x-ms-location": "query", - "x-ms-path": "$select" - }, - "expand": { - "type": "string", - "description": "OData $expand query parameter to include navigation properties like exceptionOccurrences.", - "x-ms-location": "query", - "x-ms-path": "$expand" } }, "required": [ "eventId" ] }, - "responseTemplate": "Event '{{eventId}}' retrieved (mock).", + "responseTemplate": "Event '{{eventId}}' deleted (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "getOrganization", - "description": "Retrieve the properties and relationships of the specified organization (tenant). Supports $select to choose specific fields.", + "name": "FindMeetingTimes", + "description": "Suggest possible meeting times and locations based on organizer and attendee availability. Use only when the user requests to find an open time slot for a new meeting (e.g., 'schedule a meeting', 'find a slot', 'when can we meet'). Do not use this tool for locating or updating existing meetings.", "inputSchema": { "type": "object", "properties": { - "organizationId": { + "userId": { "type": "string", - "description": "The ID of the organization (tenant) to retrieve.", + "description": "The ID or userPrincipalName of the organizer.", "x-ms-location": "path", - "x-ms-path": "organizationId" + "x-ms-path": "userId" + }, + "attendees_addresses": { + "type": "array", + "description": "Email addresses of attendees for meeting time suggestions. Each must be a valid email address.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "format": "email", + "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$" + } + }, + "attendees_types": { + "type": "array", + "description": "Attendee types corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each type applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "enum": [ + "required", + "optional", + "resource" + ] + } + }, + "attendees_names": { + "type": "array", + "description": "Display names of attendees corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each name applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string" + } }, - "select": { + "meetingDuration": { "type": "string", - "description": "Comma-separated list of organization properties to return (via $select).", - "x-ms-location": "query", - "x-ms-path": "$select" + "description": "Meeting duration (e.g. 'PT1H').", + "x-ms-location": "body", + "x-ms-path": "meetingDuration" + }, + "maxCandidates": { + "type": "integer" + }, + "isOrganizerOptional": { + "type": "boolean" + }, + "returnSuggestionReasons": { + "type": "boolean" + }, + "minimumAttendeePercentage": { + "type": "number", + "format": "double" } }, "required": [ - "organizationId" + "meetingDuration" ] }, - "responseTemplate": "Organization '{{organizationId}}' retrieved (mock).", + "responseTemplate": "Meeting time suggestions found for duration {{meetingDuration}} (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "getSchedule", - "description": "Get the free/busy schedule for a user, distribution list, or resource.", + "name": "AcceptEvent", + "description": "Accept the specified event invitation in a user's calendar.", "inputSchema": { "type": "object", "properties": { @@ -675,399 +734,204 @@ "x-ms-location": "path", "x-ms-path": "userId" }, - "schedules": { - "type": "array", - "description": "SMTP addresses of users or resources.", - "x-ms-location": "body", - "x-ms-path": "schedules", - "items": { - "type": "string" - } + "eventId": { + "type": "string", + "description": "The ID of the event to accept.", + "x-ms-location": "path", + "x-ms-path": "eventId" }, - "startTime": { - "type": "object", - "description": "Start time for the query. Should be today / after today. Use system timezone if not specified. ", + "comment": { + "type": "string", + "description": "Optional text included in the response.", "x-ms-location": "body", - "x-ms-path": "startTime", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] + "x-ms-path": "comment" }, - "endTime": { - "type": "object", - "description": "End time for the query.Should be after startTime. Use system timezone if not specified", + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer.", "x-ms-location": "body", - "x-ms-path": "endTime", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] - }, - "availabilityViewInterval": { - "type": "integer", - "description": "Time slot length in minutes." + "x-ms-path": "sendResponse" } }, "required": [ - "schedules", - "startTime", - "endTime" + "eventId" ] }, - "responseTemplate": "Schedule retrieved for {{schedules.length}} users/resources (mock).", + "responseTemplate": "Event '{{eventId}}' accepted (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "listCalendarView", - "description": "Retrieve events from a user's calendar view. Use this tool whenever you need to retrieve one meeting instance of a recurrening event(not master series) occurring in a window (e.g., 'tomorrow morning' or 'between 2 PM and 4 PM') before calling any tool that modifies, updates, or cancels a meeting.", + "name": "TentativelyAcceptEvent", + "description": "Tentatively accept a calendar event invitation. Optionally include a comment with the tentative acceptance.", "inputSchema": { "type": "object", "properties": { - "userId": { - "type": "string", - "description": "", - "x-ms-location": "path", - "x-ms-path": "userId" - }, - "startDateTime": { + "eventId": { "type": "string", - "description": "Start of the time range (ISO 8601). Should be today / after today.", - "x-ms-location": "query", - "x-ms-path": "startDateTime" + "description": "The ID of the event to tentatively accept" }, - "endDateTime": { + "comment": { "type": "string", - "description": "End of the time range (ISO 8601).should be after startDateTime.", - "x-ms-location": "query", - "x-ms-path": "endDateTime" - }, - "top": { - "type": "integer", - "description": "Max number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" + "description": "Optional comment to include with the tentative acceptance" }, - "orderby": { - "type": "string", - "description": "Order by clause (e.g. start/dateTime).", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Defaults to true." } }, "required": [ - "startDateTime", - "endDateTime" + "eventId" ] }, - "responseTemplate": "{\"value\": [{\"id\": \"mock-event-001\", \"subject\": \"Team Sync\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T10:30:00\"}, \"showAs\": \"busy\"}, {\"id\": \"mock-event-002\", \"subject\": \"Product Roadmap Review\", \"start\": {\"dateTime\": \"2026-02-01T14:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T15:00:00\"}, \"showAs\": \"busy\"}, {\"id\": \"mock-event-003\", \"subject\": \"Board Presentation\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T11:00:00\"}, \"showAs\": \"busy\"}]}", + "responseTemplate": "Event '{{eventId}}' tentatively accepted (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "listEvents", - "description": "Retrieve a list of events in a user's calendar.For recurring meetings, only return one / first record with full recurrence details (pattern, start, end) to the agent.For searching by meeting title, filter using contains(subject,'X'); avoid 'eq' or startswith(subject,'X') filter for this case.Use this tool to find existing meetings whenever the user refers to a meeting by day, date, time , or title (e.g., “add someone to the architecture review at 2 PM”), before calling any tool that modifies, updates, or cancels a meeting.", + "name": "DeclineEvent", + "description": "Decline the specified event invitation in a user's calendar.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "If no organizer is specified, use current user. If organizer is explicitly mentioned - retrieve their user principal name and use that value.", + "description": "The ID or userPrincipalName of the user.", "x-ms-location": "path", "x-ms-path": "userId" }, - "startDateTime": { + "eventId": { "type": "string", - "description": "The start of the time range for the events (ISO 8601 format). Should be today / after today.", - "x-ms-location": "query", - "x-ms-path": "startDateTime" + "description": "The ID of the event to decline.", + "x-ms-location": "path", + "x-ms-path": "eventId" }, - "endDateTime": { + "comment": { "type": "string", - "description": "The end of the time range for the events (ISO 8601 format). Should be after the startTime", - "x-ms-location": "query", - "x-ms-path": "endDateTime" + "description": "Optional text included in the response.", + "x-ms-location": "body", + "x-ms-path": "comment" }, - "top": { - "type": "integer", - "description": "The maximum number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" - }, - "filter": { - "type": "string", - "description": "OData filter query to filter events.Filter by the date, time, day if supplied in the input prompt.", - "x-ms-location": "query", - "x-ms-path": "$filter" - }, - "orderby": { - "type": "string", - "description": "OData order by query to sort events.", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer.", + "x-ms-location": "body", + "x-ms-path": "sendResponse" } - } + }, + "required": [ + "eventId" + ] }, - "responseTemplate": "{\"value\": [{\"id\": \"mock-event-001\", \"subject\": \"Team Sync\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T10:30:00\"}}, {\"id\": \"mock-event-002\", \"subject\": \"Product Roadmap Review\", \"start\": {\"dateTime\": \"2026-02-01T14:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T15:00:00\"}}, {\"id\": \"mock-event-003\", \"subject\": \"Board Presentation\", \"start\": {\"dateTime\": \"2026-02-01T10:00:00\"}, \"end\": {\"dateTime\": \"2026-02-01T11:00:00\"}}]}", + "responseTemplate": "Event '{{eventId}}' declined (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "updateEvent", - "description": "Update an existing calendar event in a specified user's calendar. Address of the attendees should be a valid email address.IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", + "name": "CancelEvent", + "description": "Cancel an event in a specified user's calendar and notify attendees.", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The ID or userPrincipalName of the user whose event is being updated.", + "description": "The ID or userPrincipalName of the user who owns the event.", "x-ms-location": "path", "x-ms-path": "userId" }, "eventId": { "type": "string", - "description": "The unique identifier of the event to update.", + "description": "The unique identifier of the event to cancel.", "x-ms-location": "path", "x-ms-path": "eventId" }, - "subject": { + "comment": { "type": "string", - "description": "The updated subject of the event.", - "x-ms-location": "body", - "x-ms-path": "subject" - }, - "body": { - "type": "object", - "description": "The updated body content of the event.", - "x-ms-location": "body", - "x-ms-path": "body", - "properties": { - "contentType": { - "type": "string", - "enum": [ - "Text", - "HTML" - ], - "description": "The content type of the body.", - "x-ms-location": "body", - "x-ms-path": "body.contentType" - }, - "content": { - "type": "string", - "description": "The body content.", - "x-ms-location": "body", - "x-ms-path": "body.content" - } - } - }, - "start": { - "type": "object", - "description": "Updated start time of the event.", - "x-ms-location": "body", - "x-ms-path": "start", - "properties": { - "dateTime": { - "type": "string", - "description": "Start date and time in ISO format.", - "x-ms-location": "body", - "x-ms-path": "start.dateTime" - }, - "timeZone": { - "type": "string", - "description": "Time zone for the start time.Use the system timezone if not explictly specified.", - "x-ms-location": "body", - "x-ms-path": "start.timeZone" - } - } - }, - "end": { - "type": "object", - "description": "Updated end time of the event.", - "x-ms-location": "body", - "x-ms-path": "end", - "properties": { - "dateTime": { - "type": "string", - "description": "End date and time in ISO format.", - "x-ms-location": "body", - "x-ms-path": "end.dateTime" - }, - "timeZone": { - "type": "string", - "description": "Time zone for the end time. Should be same as the start timezone.", - "x-ms-location": "body", - "x-ms-path": "end.timeZone" - } - } - }, - "location": { - "type": "object", - "description": "Updated location of the event.", - "x-ms-location": "body", - "x-ms-path": "location", - "properties": { - "displayName": { - "type": "string", - "description": "Display name of the location.", - "x-ms-location": "body", - "x-ms-path": "location.displayName" - } - } - }, - "attendees_addresses": { - "type": "array", - "description": "Email addresses of updated attendees. Each must be a valid email address.", - "x-ms-location": "body", - "x-ms-path": "attendees", - "x-ms-restructure": "attendees", - "items": { - "type": "string", - "format": "email", - "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$" - } - }, - "attendees_types": { - "type": "array", - "description": "Attendee types corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each type applies to the attendee at the same index.", + "description": "Optional message to include in the cancellation notification to attendees.", "x-ms-location": "body", - "x-ms-path": "attendees", - "x-ms-restructure": "attendees", - "items": { - "type": "string", - "enum": [ - "required", - "optional", - "resource" - ] - } + "x-ms-path": "comment" + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' cancelled (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ForwardEvent", + "description": "Forward a calendar event to other recipients. Can provide names or email addresses for recipients (names will be resolved to emails).", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to forward" }, - "attendees_names": { + "recipientEmails": { "type": "array", - "description": "Display names of attendees corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each name applies to the attendee at the same index.", - "x-ms-location": "body", - "x-ms-path": "attendees", - "x-ms-restructure": "attendees", + "description": "List of recipient email addresses or names to forward the event to", "items": { "type": "string" } }, - "isCancelled": { - "type": "boolean", - "description": "Set to true to cancel the event.", - "x-ms-location": "body", - "x-ms-path": "isCancelled" - }, - "recurrence": { - "type": "object", - "description": "Defines the recurrence pattern and range for the event.Default duration is 6 months.", - "x-ms-location": "body", - "x-ms-path": "recurrence", - "properties": { - "pattern": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "daily", - "weekly", - "absoluteMonthly", - "relativeMonthly", - "absoluteYearly", - "relativeYearly" - ], - "description": "The recurrence pattern type." - }, - "interval": { - "type": "integer", - "description": "The interval between occurrences." - }, - "daysOfWeek": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday" - ] - }, - "description": "The days of the week for the recurrence." - }, - "dayOfMonth": { - "type": "integer", - "description": "The day of the month for the recurrence." - }, - "month": { - "type": "integer", - "description": "The month for the yearly pattern." - } - } - }, - "range": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "endDate", - "noEnd", - "numbered" - ], - "description": "The recurrence range type. Default duration is 6 months." - }, - "startDate": { - "type": "string", - "description": "The date to start the recurrence (yyyy-mm-dd). Should be today / after today." - }, - "endDate": { - "type": "string", - "description": "The date to end the recurrence (yyyy-mm-dd). should be after startDate" - }, - "numberOfOccurrences": { - "type": "integer", - "description": "The number of times to repeat." - } - } - } - } + "comment": { + "type": "string", + "description": "Optional comment to include with the forwarded event" } }, "required": [ - "eventId" + "eventId", + "recipientEmails" ] }, - "responseTemplate": "Event '{{eventId}}' updated (mock).", + "responseTemplate": "Event '{{eventId}}' forwarded (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "GetUserDateAndTimeZoneSettings", + "description": "Get date and timezone settings for a user including time zone, date format, time format, working hours, and language preferences. Can provide name, email address, or use 'me' for current user.", + "inputSchema": { + "type": "object", + "properties": { + "userIdentifier": { + "type": "string", + "description": "User identifier - can be email, Entra ID (GUID), display name, or 'me' for current user. Defaults to 'me' if not specified." + } + }, + "required": [] + }, + "responseTemplate": "{\"timeZone\": \"Pacific Standard Time\", \"dateFormat\": \"M/d/yyyy\", \"timeFormat\": \"h:mm tt\", \"workingHours\": {\"startTime\": \"08:00:00\", \"endTime\": \"17:00:00\"}} (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "GetRooms", + "description": "Get all the meeting rooms defined in the user's tenant. Returns room names and email addresses.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + }, + "responseTemplate": "{\"value\": [{\"id\": \"mock-room-001\", \"displayName\": \"Conference Room A\", \"emailAddress\": \"confroooma@contoso.com\"}, {\"id\": \"mock-room-002\", \"displayName\": \"Conference Room B\", \"emailAddress\": \"confroomb@contoso.com\"}]} (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true } -] \ No newline at end of file +] diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_KnowledgeTools.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_KnowledgeTools.json new file mode 100644 index 00000000..c175f6e6 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_KnowledgeTools.json @@ -0,0 +1,158 @@ +[ + { + "name": "configure_federated_knowledge", + "description": "CREATE: Register new federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "knowledgeConfig": { + "type": "string", + "description": "The federated knowledge configuration details" + }, + "sourceType": { + "type": "string", + "description": "The federated knowledge source type (e.g. SharePoint, OneDrive)" + }, + "hints": { + "type": "string", + "description": "Instructions or prompt-style text to guide AI models in selecting the appropriate MCP tool." + }, + "displayName": { + "type": "string", + "description": "The federated knowledge source display name." + }, + "description": { + "type": "string", + "description": "Knowledge source description" + } + }, + "required": [ + "consumerId", + "knowledgeConfig", + "sourceType", + "displayName", + "description" + ] + }, + "responseTemplate": "Federated knowledge '{{displayName}}' configured for consumer '{{consumerId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "delete_federated_knowledge", + "description": "DELETE: Remove a federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "searchConfigurationId": { + "type": "string", + "description": "The federated knowledge configuration unique id" + }, + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + } + }, + "required": [ + "searchConfigurationId", + "consumerId" + ] + }, + "responseTemplate": "Federated knowledge configuration '{{searchConfigurationId}}' deleted for consumer '{{consumerId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ingest_federated_knowledge", + "description": "SYNC: Trigger (re)ingestion of a federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "searchConfigurationId": { + "type": "string", + "description": "The federated knowledge configuration unique id" + } + }, + "required": [ + "consumerId", + "searchConfigurationId" + ] + }, + "responseTemplate": "Ingestion triggered for configuration '{{searchConfigurationId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "query_federated_knowledge", + "description": "SEARCH: Query content in federated knowledge configurations.", + "inputSchema": { + "type": "object", + "properties": { + "maxResults": { + "type": "integer", + "description": "The max results to return" + }, + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "summarize": { + "type": "boolean", + "description": "Indicate whether the results should include summarization or not." + }, + "query": { + "type": "string", + "description": "The query to search against the knowledge sources" + }, + "searchConfigs": { + "type": "string", + "description": "List of search config to query against" + } + }, + "required": [ + "consumerId", + "query" + ] + }, + "responseTemplate": "Query '{{query}}' executed against federated knowledge for consumer '{{consumerId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "retrieve_federated_knowledge", + "description": "LIST: Return all federated knowledge configurations.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + } + }, + "required": [ + "consumerId" + ] + }, + "responseTemplate": "Federated knowledge configurations retrieved for consumer '{{consumerId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + } +] diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json index 9b22e354..27ee605f 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json @@ -1,6 +1,6 @@ [ { - "name": "AddDraftAttachmentsAsync", + "name": "AddDraftAttachments", "description": "Add attachments (URI) to an existing draft message.", "inputSchema": { "type": "object", @@ -29,7 +29,7 @@ "enabled": true }, { - "name": "UpdateDraftAsync", + "name": "UpdateDraft", "description": "Update a draft's recipients, subject, body, and attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", "inputSchema": { "type": "object", @@ -112,7 +112,7 @@ "enabled": true }, { - "name": "SendEmailWithAttachmentsAsync", + "name": "SendEmailWithAttachments", "description": "Create and send an email with optional attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", "inputSchema": { "type": "object", @@ -189,7 +189,7 @@ "enabled": true }, { - "name": "CreateDraftMessageAsync", + "name": "CreateDraftMessage", "description": "Create a draft email in the signed-in user's mailbox without sending it. IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", "inputSchema": { "type": "object", @@ -237,7 +237,7 @@ "enabled": true }, { - "name": "GetMessageAsync", + "name": "GetMessage", "description": "Get a message by ID from the signed-in user's mailbox.", "inputSchema": { "type": "object", @@ -262,7 +262,7 @@ "enabled": true }, { - "name": "UpdateMessageAsync", + "name": "UpdateMessage", "description": "Update a message's mutable properties (subject, body, categories, importance).", "inputSchema": { "type": "object", @@ -306,7 +306,37 @@ "enabled": true }, { - "name": "DeleteMessageAsync", + "name": "FlagEmail", + "description": "Update the flag status on an email message. Supports flagging, completing, or clearing a flag.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Id of the email to be flagged." + }, + "flagStatus": { + "type": "string", + "description": "Flag status to set: NotFlagged, Complete, or Flagged." + }, + "mailboxAddress": { + "type": "string", + "description": "Address of the shared mailbox to update mail." + } + }, + "required": [ + "messageId", + "flagStatus" + ] + }, + "responseTemplate": "Message '{{messageId}}' flag status updated to '{{flagStatus}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "DeleteMessage", "description": "Delete a message from the signed-in user's mailbox.", "inputSchema": { "type": "object", @@ -327,7 +357,7 @@ "enabled": true }, { - "name": "ReplyToMessageAsync", + "name": "ReplyToMessage", "description": "Send a reply to an existing message.", "inputSchema": { "type": "object", @@ -377,7 +407,7 @@ "enabled": true }, { - "name": "ReplyAllToMessageAsync", + "name": "ReplyAllToMessage", "description": "Send a reply-all to an existing message.", "inputSchema": { "type": "object", @@ -427,7 +457,7 @@ "enabled": true }, { - "name": "SendDraftMessageAsync", + "name": "SendDraftMessage", "description": "Send an existing draft message by ID.", "inputSchema": { "type": "object", @@ -448,7 +478,7 @@ "enabled": true }, { - "name": "SearchMessagesAsync", + "name": "SearchMessages", "description": "Search Outlook messages using Microsoft Graph Search API with KQL-style queries.", "inputSchema": { "type": "object", @@ -483,7 +513,7 @@ "enabled": true }, { - "name": "GetAttachmentsAsync", + "name": "GetAttachments", "description": "Get all attachments from a message, returning attachment metadata (ID, name, size, type).", "inputSchema": { "type": "object", @@ -504,7 +534,7 @@ "enabled": true }, { - "name": "DownloadAttachmentAsync", + "name": "DownloadAttachment", "description": "Download attachment content from a message. Returns the content as base64-encoded string.", "inputSchema": { "type": "object", @@ -530,7 +560,7 @@ "enabled": true }, { - "name": "UploadAttachmentAsync", + "name": "UploadAttachment", "description": "Upload a small file attachment (less than 3 MB) to a message. File content must be base64-encoded.", "inputSchema": { "type": "object", @@ -565,7 +595,7 @@ "enabled": true }, { - "name": "UploadLargeAttachmentAsync", + "name": "UploadLargeAttachment", "description": "Upload a large file attachment (3-150 MB) to a message using chunked upload. File content must be base64-encoded.", "inputSchema": { "type": "object", @@ -600,7 +630,7 @@ "enabled": true }, { - "name": "DeleteAttachmentAsync", + "name": "DeleteAttachment", "description": "Delete an attachment from a message.", "inputSchema": { "type": "object", @@ -626,7 +656,7 @@ "enabled": true }, { - "name": "ReplyWithFullThreadAsync", + "name": "ReplyWithFullThread", "description": "Reply (or reply-all) adding new recipients while preserving full quoted thread and optionally re-attaching original files.", "inputSchema": { "type": "object", @@ -684,7 +714,7 @@ "enabled": true }, { - "name": "ReplyAllWithFullThreadAsync", + "name": "ReplyAllWithFullThread", "description": "Reply-all adding new recipients while preserving full quoted thread and optionally re-attaching original files.", "inputSchema": { "type": "object", @@ -738,7 +768,7 @@ "enabled": true }, { - "name": "ForwardMessageWithFullThreadAsync", + "name": "ForwardMessageWithFullThread", "description": "Forward a message adding new recipients and an optional intro comment while preserving full quoted thread; returns sensitivity label.", "inputSchema": { "type": "object", @@ -792,7 +822,7 @@ "enabled": true }, { - "name": "ForwardMessageAsync", + "name": "ForwardMessage", "description": "Forward an existing message, optionally adding a comment, recipients, and new attachments while preserving the quoted thread.", "inputSchema": { "type": "object", @@ -874,4 +904,4 @@ "statusCode": 200, "enabled": true } -] \ No newline at end of file +] diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json index 5080068f..26a7d8cb 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json @@ -1,6 +1,6 @@ [ { - "name": "getMyProfile", + "name": "GetMyDetails", "description": "Get the profile of the currently signed-in user, including their display name, email address, job title, and other basic information.", "inputSchema": { "type": "object", @@ -19,7 +19,7 @@ "enabled": true }, { - "name": "listUsers", + "name": "GetMultipleUsersDetails", "description": "Search for users in the organization directory. Use this to find a person's email address when you only know their name. Supports filtering by displayName, mail, userPrincipalName, and other properties.", "inputSchema": { "type": "object", @@ -54,7 +54,7 @@ "enabled": true }, { - "name": "getUser", + "name": "GetUserDetails", "description": "Get the profile of a specific user by their ID or userPrincipalName.", "inputSchema": { "type": "object", @@ -77,7 +77,7 @@ "enabled": true }, { - "name": "getManager", + "name": "GetManagerDetails", "description": "Get the manager of a specific user or the currently signed-in user.", "inputSchema": { "type": "object", @@ -96,7 +96,7 @@ "enabled": true }, { - "name": "getDirectReports", + "name": "GetDirectReportsDetails", "description": "Get the direct reports of a specific user or the currently signed-in user.", "inputSchema": { "type": "object", @@ -119,4 +119,3 @@ "enabled": true } ] - diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md new file mode 100644 index 00000000..50586714 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md @@ -0,0 +1,86 @@ +# Mock Tooling Server Snapshots + +## What Are Snapshot Files? + +Snapshot files capture the real tool catalog from live M365 MCP servers at a specific point in time. They serve as a ground-truth reference for verifying that the mock tool definitions in `../mocks/` accurately reflect the tools exposed by the production M365 MCP endpoints. + +Each snapshot file records the complete set of tool names, descriptions, and input schemas returned by one M365 MCP server. By comparing snapshots against the mock definitions, fidelity tests can detect drift: tools that have been added, removed, or changed upstream but not yet reflected in the mocks. + +## Snapshot File Schema + +Each snapshot file follows this JSON structure: + +```json +{ + "$schema": "mock-snapshot-schema", + "capturedAt": "", + "serverName": "", + "sourceNote": "Run MockToolSnapshotCaptureTests with MCP_BEARER_TOKEN set to populate this file.", + "tools": [ + { + "name": "", + "description": "", + "inputSchema": { } + } + ] +} +``` + +| Field | Description | +|---|---| +| `$schema` | Always `"mock-snapshot-schema"`. Reserved for future formal JSON Schema validation. | +| `capturedAt` | ISO 8601 UTC timestamp of when the snapshot was captured. `"UNPOPULATED"` means the file has never been populated with real data. | +| `serverName` | The M365 MCP server name this snapshot corresponds to (e.g., `mcp_CalendarTools`, `mcp_MailTools`, `mcp_MeServer`, `mcp_KnowledgeTools`). | +| `sourceNote` | Human-readable note explaining how to populate the file. | +| `tools` | Array of tool definitions. Each entry has `name` (string), `description` (string), and `inputSchema` (JSON Schema object). | + +## How to Update Snapshots + +Snapshots are refreshed using `MockToolSnapshotCaptureTests` — a set of integration +tests that query live M365 MCP servers and either detect drift or write updated +snapshot files. + +Prerequisites: +- A valid M365 bearer token for the Agent 365 Tools resource + +### Obtain a bearer token + +With an agent project (ToolingManifest.json present): +```powershell +$env:MCP_BEARER_TOKEN = a365 develop get-token --output raw +``` + +Without an agent project — pass your app registration client ID and explicit scopes. +Your app registration must have delegated permissions on the Agent 365 Tools resource +(`ea9ffc3e-8a23-4a7d-836d-234d7c7565c1`): +```powershell +$env:MCP_BEARER_TOKEN = a365 develop get-token ` + --app-id ` + --scopes McpServers.Mail.All McpServers.Calendar.All McpServers.Me.All McpServers.Knowledge.All ` + --output raw +``` + +### Detect drift (read-only) + +Fails the test if the live server differs from the snapshot — nothing is written: +```bash +dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +``` + +### Refresh snapshot files + +Writes updated snapshot files to disk for review and commit: +```bash +MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +``` + +After refreshing, update the corresponding mock files in `../mocks/` to match +any new or changed tools, then run `MockToolFidelityTests` to confirm coverage. + +## UNPOPULATED Snapshots + +When `capturedAt` is `"UNPOPULATED"`, the snapshot file contains no real tool data. This is the initial state of all snapshot files when they are first created. + +Fidelity tests (`MockToolFidelityTests`) will **skip** (not fail) when a snapshot is UNPOPULATED. Once a snapshot has been populated with real data from a live M365 server, fidelity tests will enforce full coverage, flagging any tools present in the snapshot but missing from the mocks, and vice versa. + +To populate snapshots, run the update script as described above. diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_CalendarTools.snapshot.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_CalendarTools.snapshot.json new file mode 100644 index 00000000..be6e1337 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_CalendarTools.snapshot.json @@ -0,0 +1,605 @@ +{ + "$schema": "mock-snapshot-schema", + "capturedAt": "2026-02-28T17:53:29Z", + "serverName": "mcp_CalendarTools", + "tools": [ + { + "name": "ListEvents", + "description": "Retrieve a list of events for the user with a given criteria - start and end datetimes, title of the meeting, attendee emails etc. This returns only the master event for recurring meetings.", + "inputSchema": { + "type": "object", + "properties": { + "startDateTime": { + "type": "string", + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00Z'). Default : user'sCurrentTime." + }, + "endDateTime": { + "type": "string", + "description": "End date/time in ISO 8601 format (e.g., '2026-01-15T17:00:00Z'). Default : currentTime + 90 days." + }, + "meetingTitle": { + "type": "string", + "description": "Meeting title to search by" + }, + "attendeeEmails": { + "type": "object", + "properties": { + "Item": { + "type": "string", + "description": "" + } + }, + "required": [] + }, + "timeZone": { + "type": "string", + "description": "timezone for the event (e.g., 'Pacific Standard Time'). Default : current user's timezone." + }, + "select": { + "type": "string", + "description": "Comma-separated list of properties to return" + }, + "top": { + "type": "object", + "properties": {}, + "required": [] + }, + "orderby": { + "type": "string", + "description": "Property name to sort results by (e.g., 'displayName', 'jobTitle'). Meeting startTime is specified if not used." + } + }, + "required": [] + } + }, + { + "name": "ListCalendarView", + "description": "Retrieve events from a user's calendar view with recurring events expanded into individual instances. Use this whenever you need to find user's meetings in general, or one meeting instance of a recurring event (not master series) occurring in a time window before modifying, updating, or canceling a meeting.", + "inputSchema": { + "type": "object", + "properties": { + "userIdentifier": { + "type": "string", + "description": "User identifier - can be email, Entra ID (GUID), display name, or 'me' for current user. Defaults to 'me' if not specified." + }, + "startDateTime": { + "type": "string", + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00'). Default: current time of current user" + }, + "endDateTime": { + "type": "string", + "description": "End date/time in ISO 8601 format (e.g., '2026-01-15T17:00:00'). Default: current time of current user + 15 days" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the event (e.g., 'Pacific Standard Time'). Default : current user's timezone." + }, + "subject": { + "type": "string", + "description": "Search term to filter events by subject/title. Use this to find specific meetings by name." + }, + "select": { + "type": "string", + "description": "Comma-separated list of properties to return" + }, + "top": { + "type": "object", + "properties": {}, + "required": [] + }, + "orderby": { + "type": "string", + "description": "Property name to sort results by (e.g., 'start/dateTime'). Default is 'start/dateTime'." + } + }, + "required": [ + "userIdentifier" + ] + } + }, + { + "name": "CreateEvent", + "description": "Create a new calendar event. Can provide names or email addresses for attendees (names will be resolved to emails). If time is provided, schedules even if there are conflicts. Default duration is 30 minutes. All events automatically include a Teams meeting link. Supports recurring events.", + "inputSchema": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Event title/subject" + }, + "attendeeEmails": { + "type": "array", + "description": "List of email or userPrincipalNames of attendees.Please fetch the email addresses of the users before making this call.", + "items": { + "type": "string" + } + }, + "startDateTime": { + "type": "string", + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00')" + }, + "endDateTime": { + "type": "string", + "description": "End date/time in ISO 8601 format (e.g., '2026-01-15T09:30:00')" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the event (e.g., 'Pacific Standard Time'). Default is current user's timezone." + }, + "bodyContent": { + "type": "string", + "description": "Event body/description content" + }, + "bodyContentType": { + "type": "string", + "description": "Body content type: 'Text' or 'HTML'. Default is 'Text'." + }, + "location": { + "type": "string", + "description": "Event location display name" + }, + "isOnlineMeeting": { + "type": "boolean", + "description": "Whether to create as an online meeting. Defaults to true." + }, + "onlineMeetingProvider": { + "type": "string", + "description": "Online meeting provider: 'teamsForBusiness', 'skypeForBusiness', 'skypeForConsumer'. Defaults to 'teamsForBusiness'." + }, + "allowNewTimeProposals": { + "type": "object", + "properties": {}, + "required": [] + }, + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "" + }, + "interval": { + "type": "integer", + "description": "" + }, + "daysOfWeek": { + "type": "array", + "description": "", + "items": { + "type": "string" + } + }, + "dayOfMonth": { + "type": "object", + "properties": {}, + "required": [] + }, + "month": { + "type": "object", + "properties": {}, + "required": [] + }, + "index": { + "type": "string", + "description": "" + }, + "firstDayOfWeek": { + "type": "string", + "description": "" + } + }, + "required": [ + "interval" + ] + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "" + }, + "startDate": { + "type": "string", + "description": "" + }, + "endDate": { + "type": "string", + "description": "" + }, + "numberOfOccurrences": { + "type": "object", + "properties": {}, + "required": [] + } + }, + "required": [] + } + }, + "required": [] + }, + "importance": { + "type": "string", + "description": "Event importance: 'low', 'normal', 'high'. Default is 'normal'." + }, + "sensitivity": { + "type": "string", + "description": "Event sensitivity: 'normal', 'personal', 'private', 'confidential'. Default is 'normal'." + }, + "showAs": { + "type": "string", + "description": "Free/busy status to show: 'free', 'tentative', 'busy', 'oof', 'workingElsewhere', 'unknown'. Default is 'busy'." + }, + "responseRequested": { + "type": "boolean", + "description": "Whether a response is requested from invitees. Default is true." + } + }, + "required": [ + "subject", + "attendeeEmails", + "startDateTime", + "endDateTime" + ] + } + }, + { + "name": "UpdateEvent", + "description": "Update an existing calendar event. Can add or remove attendees by providing names or email addresses (names will be resolved to emails automatically).", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to update" + }, + "subject": { + "type": "string", + "description": "Updated event title/subject" + }, + "startDateTime": { + "type": "string", + "description": "Updated start date/time in ISO 8601 format" + }, + "endDateTime": { + "type": "string", + "description": "Updated end date/time in ISO 8601 format" + }, + "timeZone": { + "type": "string", + "description": "Updated Time zone for the event (e.g., 'Pacific Standard Time')." + }, + "attendeesToAdd": { + "type": "array", + "description": "List of attendee email addresses or names to add", + "items": { + "type": "string" + } + }, + "attendeesToRemove": { + "type": "array", + "description": "List of attendee email addresses or names to remove", + "items": { + "type": "string" + } + }, + "body": { + "type": "string", + "description": "Updated event body/description" + }, + "location": { + "type": "string", + "description": "Updated event location" + }, + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "" + }, + "interval": { + "type": "integer", + "description": "" + }, + "daysOfWeek": { + "type": "array", + "description": "", + "items": { + "type": "string" + } + }, + "dayOfMonth": { + "type": "object", + "properties": {}, + "required": [] + }, + "month": { + "type": "object", + "properties": {}, + "required": [] + }, + "index": { + "type": "string", + "description": "" + }, + "firstDayOfWeek": { + "type": "string", + "description": "" + } + }, + "required": [ + "interval" + ] + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "" + }, + "startDate": { + "type": "string", + "description": "" + }, + "endDate": { + "type": "string", + "description": "" + }, + "numberOfOccurrences": { + "type": "object", + "properties": {}, + "required": [] + } + }, + "required": [] + } + }, + "required": [] + }, + "importance": { + "type": "string", + "description": "Updated event importance: 'low', 'normal', 'high'" + }, + "sensitivity": { + "type": "string", + "description": "Updated event sensitivity: 'normal', 'personal', 'private', 'confidential'" + }, + "showAs": { + "type": "string", + "description": "Updated free/busy status to show: 'free', 'tentative', 'busy', 'oof', 'workingElsewhere', 'unknown'" + }, + "responseRequested": { + "type": "object", + "properties": {}, + "required": [] + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "DeleteEventById", + "description": "Delete a calendar event. Retrieve the event details first to extract the ID, then pass it to be deleted.", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to delete." + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "FindMeetingTimes", + "description": "Find meeting times that work for all attendees. Suggests meeting times based on organizer and attendee availability. Can provide names or email addresses for attendees (names will be resolved to emails).", + "inputSchema": { + "type": "object", + "properties": { + "userIdentifier": { + "type": "string", + "description": "User identifier for the organizer - can be email, Entra ID (GUID), display name, or 'me' for current user. Defaults to 'me' if not specified." + }, + "attendeeEmails": { + "type": "array", + "description": "List of attendee email addresses or names", + "items": { + "type": "string" + } + }, + "meetingDuration": { + "type": "string", + "description": "Meeting duration in ISO 8601 format (e.g., 'PT1H' for 1 hour, 'PT30M' for 30 minutes). Required." + }, + "startDateTime": { + "type": "string", + "description": "Start of the time range in ISO 8601 format (e.g., '2026-01-15T09:00:00'). Defaults to current time." + }, + "endDateTime": { + "type": "string", + "description": "End of the time range in ISO 8601 format (e.g., '2026-01-15T17:00:00'). Defaults to start time + 7 days." + }, + "timeZone": { + "type": "string", + "description": "Time zone (e.g., 'Pacific Standard Time'). Default is current user's timezone." + }, + "maxCandidates": { + "type": "object", + "properties": {}, + "required": [] + }, + "isOrganizerOptional": { + "type": "boolean", + "description": "Whether the organizer's attendance is optional. Default is false." + }, + "returnSuggestionReasons": { + "type": "boolean", + "description": "Whether to return reasons for each suggestion. Default is true." + }, + "minimumAttendeePercentage": { + "type": "object", + "properties": {}, + "required": [] + } + }, + "required": [] + } + }, + { + "name": "AcceptEvent", + "description": "Accept a calendar event invitation. Optionally include a comment with the acceptance.", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to accept" + }, + "comment": { + "type": "string", + "description": "Optional comment to include with the acceptance" + }, + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Defaults to true." + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "TentativelyAcceptEvent", + "description": "Tentatively accept a calendar event invitation. Optionally include a comment with the tentative acceptance.", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to tentatively accept" + }, + "comment": { + "type": "string", + "description": "Optional comment to include with the tentative acceptance" + }, + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Defaults to true." + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "DeclineEvent", + "description": "Decline a calendar event invitation. Optionally include a comment with the decline.", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to decline" + }, + "comment": { + "type": "string", + "description": "Optional comment to include with the decline" + }, + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Defaults to true." + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "CancelEvent", + "description": "Cancel a calendar event. Only the event organizer can cancel an event. This will send cancellation notifications to all attendees.", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to cancel" + }, + "comment": { + "type": "string", + "description": "Optional comment to include with the cancellation" + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "ForwardEvent", + "description": "Forward a calendar event to other recipients. Can provide names or email addresses for recipients (names will be resolved to emails).", + "inputSchema": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "description": "The ID of the event to forward" + }, + "recipientEmails": { + "type": "array", + "description": "List of recipient email addresses or names to forward the event to", + "items": { + "type": "string" + } + }, + "comment": { + "type": "string", + "description": "Optional comment to include with the forwarded event" + } + }, + "required": [ + "eventId", + "recipientEmails" + ] + } + }, + { + "name": "GetUserDateAndTimeZoneSettings", + "description": "Get date and timezone settings for a user including time zone, date format, time format, working hours, and language preferences. Can provide name, email address, or use 'me' for current user.", + "inputSchema": { + "type": "object", + "properties": { + "userIdentifier": { + "type": "string", + "description": "User identifier - can be email, Entra ID (GUID), display name, or 'me' for current user. Defaults to 'me' if not specified." + } + }, + "required": [] + } + }, + { + "name": "GetRooms", + "description": "Get all the meeting rooms defined in the user's tenant. Returns room names and email addresses.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ] +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_KnowledgeTools.snapshot.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_KnowledgeTools.snapshot.json new file mode 100644 index 00000000..68fd5829 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_KnowledgeTools.snapshot.json @@ -0,0 +1,138 @@ +{ + "$schema": "mock-snapshot-schema", + "capturedAt": "2026-02-28T17:53:31Z", + "serverName": "mcp_KnowledgeTools", + "tools": [ + { + "name": "configure_federated_knowledge", + "description": "CREATE: Register new federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "knowledgeConfig": { + "type": "string", + "description": "The federated knowledge configuration details" + }, + "sourceType": { + "type": "string", + "description": "The federated knowledge source type (e.g. SharePoint, OneDrive)" + }, + "hints": { + "type": "string", + "description": "Instructions instructions or prompt-style text to guide AI models in selecting the appropriate MCP tool." + }, + "displayName": { + "type": "string", + "description": "The federated knowledge source display name." + }, + "description": { + "type": "string", + "description": "Knowledge source descrption" + } + }, + "required": [ + "consumerId", + "knowledgeConfig", + "sourceType", + "displayName", + "description" + ] + } + }, + { + "name": "delete_federated_knowledge", + "description": "DELETE: Remove a federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "searchConfigurationId": { + "type": "string", + "description": "The federated knowledge configuration uniqie id" + }, + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + } + }, + "required": [ + "searchConfigurationId", + "consumerId" + ] + } + }, + { + "name": "ingest_federated_knowledge", + "description": "SYNC: Trigger (re)ingestion of a federated knowledge configuration.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "searchConfigurationId": { + "type": "string", + "description": "The federated knowledge configuration uniqie id" + } + }, + "required": [ + "consumerId", + "searchConfigurationId" + ] + } + }, + { + "name": "query_federated_knowledge", + "description": "SEARCH: Query content in federated knowledge configurations.", + "inputSchema": { + "type": "object", + "properties": { + "maxResults": { + "type": "integer", + "description": "The max results to return" + }, + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + }, + "summarize": { + "type": "boolean", + "description": "Indicate whether the results should include summarization or not." + }, + "query": { + "type": "string", + "description": "The query to search against the knowledge sources" + }, + "searchConfigs": { + "type": "string", + "description": "List of search config to query against" + } + }, + "required": [ + "consumerId", + "query" + ] + } + }, + { + "name": "retrieve_federated_knowledge", + "description": "LIST: Return all federated knowledge configurations.", + "inputSchema": { + "type": "object", + "properties": { + "consumerId": { + "type": "string", + "description": "The unique ID of the consumer (e.g., agent) associated with the federated knowledge source." + } + }, + "required": [ + "consumerId" + ] + } + } + ] +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MailTools.snapshot.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MailTools.snapshot.json new file mode 100644 index 00000000..c1175297 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MailTools.snapshot.json @@ -0,0 +1,810 @@ +{ + "$schema": "mock-snapshot-schema", + "capturedAt": "2026-02-28T17:53:30Z", + "serverName": "mcp_MailTools", + "tools": [ + { + "name": "AddDraftAttachments", + "description": "Add attachments (URI) to an existing draft message.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Graph message ID (draft) to update." + }, + "attachmentUris": { + "type": "array", + "description": "List of direct file URIs to attach (must be Microsoft 365 file links: OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id}).", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId", + "attachmentUris" + ] + } + }, + { + "name": "UpdateDraft", + "description": "Update a draft's recipients, subject, body, and attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). Recipients can be provided as names or email addresses - names will be automatically resolved to email addresses using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Graph message ID (draft) to update." + }, + "to": { + "type": "array", + "description": "List of To recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "description": "Subject of the draft" + }, + "body": { + "type": "string", + "description": "Body of the draft" + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string", + "description": "" + }, + "ContentBase64": { + "type": "string", + "description": "" + }, + "ContentType": { + "type": "string", + "description": "" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId" + ] + } + }, + { + "name": "SendEmailWithAttachments", + "description": "Create and send an email with optional attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). Recipients can be provided as names or email addresses - names will be automatically resolved to email addresses using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "array", + "description": "List of To recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "description": "Subject of the email" + }, + "body": { + "type": "string", + "description": "Body of the email" + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string", + "description": "" + }, + "ContentBase64": { + "type": "string", + "description": "" + }, + "ContentType": { + "type": "string", + "description": "" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [] + } + }, + { + "name": "CreateDraftMessage", + "description": "Create a draft email in the signed-in user's mailbox without sending it. Recipients can be provided as names or email addresses - names will be automatically resolved to email addresses using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject of the email" + }, + "body": { + "type": "string", + "description": "Body of the email" + }, + "to": { + "type": "array", + "description": "List of To recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "contentType": { + "type": "string", + "description": "Body content type: Text or HTML" + } + }, + "required": [] + } + }, + { + "name": "GetMessage", + "description": "Get a message by ID from the signed-in user's mailbox.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, request HTML body format" + }, + "bodyPreviewOnly": { + "type": "boolean", + "description": "If true, returns only the body preview (~255 chars) instead of the full body. Useful for reducing payload size." + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "UpdateMessage", + "description": "Update a message's mutable properties (subject, body, categories, importance).", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + }, + "subject": { + "type": "string", + "description": "New subject" + }, + "body": { + "type": "string", + "description": "New body content" + }, + "contentType": { + "type": "string", + "description": "Body content type: Text or HTML" + }, + "categories": { + "type": "array", + "description": "Message categories", + "items": { + "type": "string" + } + }, + "importance": { + "type": "string", + "description": "Importance level: Low, Normal, or High" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "FlagEmail", + "description": "Update the flag status on an email message. Supports flagging, completing, or clearing a flag.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Id of the email to be flagged." + }, + "flagStatus": { + "type": "string", + "description": "Flag status to set: NotFlagged, Complete, or Flagged." + }, + "mailboxAddress": { + "type": "string", + "description": "Address of the shared mailbox to update mail." + } + }, + "required": [ + "messageId", + "flagStatus" + ] + } + }, + { + "name": "DeleteMessage", + "description": "Delete a message from the signed-in user's mailbox.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "ReplyToMessage", + "description": "Send a reply to an existing message. Recipients can be provided as names or email addresses - names will be automatically resolved using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID to reply to" + }, + "comment": { + "type": "string", + "description": "Reply text content" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, treat comment as HTML" + }, + "toRecipients": { + "type": "array", + "description": "Optional override list of To recipients (can be names or email addresses - names will be automatically resolved). If provided, a reply draft is created and patched before sending.", + "items": { + "type": "string" + } + }, + "ccRecipients": { + "type": "array", + "description": "Optional override list of Cc recipients (can be names or email addresses - names will be automatically resolved). Used only when modifying recipients.", + "items": { + "type": "string" + } + }, + "bccRecipients": { + "type": "array", + "description": "Optional override list of Bcc recipients (can be names or email addresses - names will be automatically resolved). Used only when modifying recipients.", + "items": { + "type": "string" + } + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "ReplyAllToMessage", + "description": "Send a reply-all to an existing message. Recipients can be provided as names or email addresses - names will be automatically resolved using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID to reply-all to" + }, + "comment": { + "type": "string", + "description": "Reply text content" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, treat comment as HTML" + }, + "toRecipients": { + "type": "array", + "description": "Optional override list of To recipients (can be names or email addresses - names will be automatically resolved). If provided, a reply-all draft is created and patched before sending.", + "items": { + "type": "string" + } + }, + "ccRecipients": { + "type": "array", + "description": "Optional override list of Cc recipients (can be names or email addresses - names will be automatically resolved). Used only when modifying recipients.", + "items": { + "type": "string" + } + }, + "bccRecipients": { + "type": "array", + "description": "Optional override list of Bcc recipients (can be names or email addresses - names will be automatically resolved). Used only when modifying recipients.", + "items": { + "type": "string" + } + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "SendDraftMessage", + "description": "Send an existing draft message by ID.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Draft message ID to send" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "SearchMessages", + "description": "Search for email messages using natural language queries powered by Microsoft 365 Copilot. This tool searches across your mailbox to find relevant emails. Use natural language to describe what you're looking for (e.g., 'emails from Sarah about the budget', 'unread messages from this week', 'messages with attachments about the project'). The search focuses specifically on email content and metadata.", + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Natural language search query for finding emails (e.g., 'emails from John about the project', 'unread messages from last week')" + }, + "conversationId": { + "type": "string", + "description": "Existing conversation id (GUID). Auto-created if missing." + } + }, + "required": [ + "message" + ] + } + }, + { + "name": "GetAttachments", + "description": "Get all attachments from a message, returning attachment metadata (ID, name, size, type).", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + } + }, + "required": [ + "messageId" + ] + } + }, + { + "name": "DownloadAttachment", + "description": "Download attachment content from a message. Returns the content as base64-encoded string.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + }, + "attachmentId": { + "type": "string", + "description": "Attachment ID" + } + }, + "required": [ + "messageId", + "attachmentId" + ] + } + }, + { + "name": "UploadAttachment", + "description": "Upload a small file attachment (less than 3 MB) to a message. File content must be base64-encoded.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID to attach to" + }, + "fileName": { + "type": "string", + "description": "File name" + }, + "contentBase64": { + "type": "string", + "description": "Base64-encoded file content" + }, + "contentType": { + "type": "string", + "description": "MIME type (optional)" + } + }, + "required": [ + "messageId", + "fileName", + "contentBase64" + ] + } + }, + { + "name": "UploadLargeAttachment", + "description": "Upload a large file attachment (3-150 MB) to a message using chunked upload. File content must be base64-encoded.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID to attach to" + }, + "fileName": { + "type": "string", + "description": "File name" + }, + "contentBase64": { + "type": "string", + "description": "Base64-encoded file content" + }, + "contentType": { + "type": "string", + "description": "MIME type (optional)" + } + }, + "required": [ + "messageId", + "fileName", + "contentBase64" + ] + } + }, + { + "name": "DeleteAttachment", + "description": "Delete an attachment from a message.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + }, + "attachmentId": { + "type": "string", + "description": "Attachment ID" + } + }, + "required": [ + "messageId", + "attachmentId" + ] + } + }, + { + "name": "ReplyWithFullThread", + "description": "Reply (or reply-all) adding new recipients while preserving full quoted thread and optionally re-attaching original files. Recipients can be provided as names or email addresses - names will be automatically resolved using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to reply to" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "replyAll": { + "type": "boolean", + "description": "If true, perform reply-all; otherwise a direct reply" + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + } + }, + { + "name": "ReplyAllWithFullThread", + "description": "Reply-all adding new recipients while preserving full quoted thread and optionally re-attaching original files.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to reply-all to" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + } + }, + { + "name": "ForwardMessageWithFullThread", + "description": "Forward a message adding new recipients and an optional intro comment while preserving full quoted thread; returns sensitivity label. Recipients can be provided as names or email addresses - names will be automatically resolved using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to forward" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (can be names or email addresses - names will be automatically resolved) - required", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + } + }, + { + "name": "ForwardMessage", + "description": "Forward an existing message, optionally adding a comment, recipients, and new attachments while preserving the quoted thread. Recipients can be provided as names or email addresses - names will be automatically resolved using Microsoft Graph.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to forward" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (can be names or email addresses - names will be automatically resolved) - required", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (can be names or email addresses - names will be automatically resolved)", + "items": { + "type": "string" + } + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string", + "description": "" + }, + "ContentBase64": { + "type": "string", + "description": "" + }, + "ContentType": { + "type": "string", + "description": "" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId" + ] + } + } + ] +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MeServer.snapshot.json b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MeServer.snapshot.json new file mode 100644 index 00000000..1079555a --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MeServer.snapshot.json @@ -0,0 +1,129 @@ +{ + "$schema": "mock-snapshot-schema", + "capturedAt": "2026-02-28T17:53:30Z", + "serverName": "mcp_MeServer", + "tools": [ + { + "name": "GetMyDetails", + "description": "Retrieve profile details for the currently signed-in user (\"me, my\"). Use this when you need the signed-in user's identity or profile details (e.g., display name, email, job title), including for email/calendar scenarios and for questions like \"who are you\" or any identity question using \"you\" or \"your\".", + "inputSchema": { + "type": "object", + "properties": { + "select": { + "type": "string", + "description": "Always pass in comma-separated list of properties you need" + }, + "expand": { + "type": "string", + "description": "Expand related entities" + } + }, + "required": [] + } + }, + { + "name": "GetUserDetails", + "description": "Find a specified user's profile by name, email, or ID. Use this when you need to look up a specific person in your organization.", + "inputSchema": { + "type": "object", + "properties": { + "userIdentifier": { + "type": "string", + "description": "The user's name or object ID (GUID) or userPrincipalName (email-like UPN)." + }, + "select": { + "type": "string", + "description": "Always pass in comma-separated list of properties you need" + }, + "expand": { + "type": "string", + "description": "Expand a related entity for the user" + } + }, + "required": [ + "userIdentifier" + ] + } + }, + { + "name": "GetMultipleUsersDetails", + "description": "Search for multiple users in the directory by name, job title, office location, or other properties.", + "inputSchema": { + "type": "object", + "properties": { + "searchValues": { + "type": "array", + "description": "List of search terms (e.g., ['John Smith', 'Jane Doe'] or ['Software Engineer', 'Product Manager'] or ['Building 40', 'Building 41']). Each term is searched independently - results include users matching ANY term.", + "items": { + "type": "string" + } + }, + "propertyToSearchBy": { + "type": "string", + "description": "User property to search (e.g., 'displayName', 'jobTitle', 'officeLocation', 'userPrincipalName', 'id')." + }, + "select": { + "type": "string", + "description": "Comma-separated list of user properties to include in response (e.g., 'displayName,mail,jobTitle,officeLocation,mobilePhone')" + }, + "expand": { + "type": "string", + "description": "Navigation properties to expand (e.g., 'manager' to include manager details)" + }, + "top": { + "type": "object", + "properties": {}, + "required": [] + }, + "orderby": { + "type": "string", + "description": "Property name to sort results by (e.g., 'displayName', 'jobTitle')" + } + }, + "required": [ + "searchValues" + ] + } + }, + { + "name": "GetManagerDetails", + "description": "Get a user's manager information - name, email, job title, etc.,", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Name of the user whose manager to retrieve. Use \"me\" for current / signed-in user." + }, + "select": { + "type": "string", + "description": "Always pass in comma-separated list of properties you need" + } + }, + "required": [ + "userId" + ] + } + }, + { + "name": "GetDirectReportsDetails", + "description": "Retrieve a user's team, or direct reports (people who report to them in the organizational hierarchy). Use this for organizational team structure, NOT for Microsoft Teams workspace membership. Examples for calling this tool: 'set up meeting with John's team', 'who reports to Sarah', 'list manager's direct reports'.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Name of the user whose direct reports (organizational team members) to retrieve. Use \"me\" for current / signed-in user." + }, + "select": { + "type": "string", + "description": "Always pass in comma-separated list of properties you need for each direct report" + } + }, + "required": [ + "userId" + ] + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs new file mode 100644 index 00000000..892b8ca6 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.MockTools; + +/// +/// Verifies that mock tool definitions stay in sync with real M365 MCP server snapshots. +/// Each snapshot file drives a separate test case via . +/// +public class MockToolFidelityTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// Discovers all snapshot files under the MockToolingServer/snapshots directory. + /// Returns one object[] per file so xUnit shows each server as a separate test case. + /// + public static IEnumerable GetSnapshotFiles() + { + var snapshotsDir = GetSnapshotsDirectory(); + var files = Directory.GetFiles(snapshotsDir, "*.snapshot.json"); + + foreach (var file in files) + { + yield return new object[] { file }; + } + } + + [Theory] + [MemberData(nameof(GetSnapshotFiles))] + public void SnapshotTools_ShouldAllExistInMockDefinition(string snapshotFilePath) + { + // Arrange + var snapshot = LoadSnapshot(snapshotFilePath); + + if (string.Equals(snapshot.CapturedAt, "UNPOPULATED", StringComparison.OrdinalIgnoreCase)) + { + // Snapshot has no real data yet. Pass vacuously until populated via + // MockToolSnapshotCaptureTests (set MCP_BEARER_TOKEN and run: + // dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests"). + return; + } + + snapshot.Tools.Should().NotBeEmpty( + $"Snapshot '{snapshot.ServerName}' is marked as populated (capturedAt={snapshot.CapturedAt}) " + + "but contains no tools. Re-capture the snapshot or mark it UNPOPULATED."); + + var mockTools = LoadEnabledMockTools(snapshot.ServerName); + var mockToolNames = new HashSet(mockTools.Select(t => t.Name)); + + // Act & Assert - every snapshot tool must exist in the mock + foreach (var snapshotTool in snapshot.Tools) + { + mockToolNames.Should().Contain( + snapshotTool.Name, + $"Snapshot tool '{snapshotTool.Name}' for server '{snapshot.ServerName}' " + + $"is missing from the mock definition. Add it to mocks/{snapshot.ServerName}.json."); + } + } + + [Theory] + [MemberData(nameof(GetSnapshotFiles))] + public void MockTools_ShouldAllExistInSnapshot(string snapshotFilePath) + { + // Arrange + var snapshot = LoadSnapshot(snapshotFilePath); + + if (string.Equals(snapshot.CapturedAt, "UNPOPULATED", StringComparison.OrdinalIgnoreCase)) + { + // Snapshot has no real data yet. Pass vacuously until populated via + // MockToolSnapshotCaptureTests (set MCP_BEARER_TOKEN and run: + // dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests"). + return; + } + + snapshot.Tools.Should().NotBeEmpty( + $"Snapshot '{snapshot.ServerName}' is marked as populated (capturedAt={snapshot.CapturedAt}) " + + "but contains no tools. Re-capture the snapshot or mark it UNPOPULATED."); + + var mockTools = LoadEnabledMockTools(snapshot.ServerName); + var snapshotToolNames = new HashSet(snapshot.Tools.Select(t => t.Name)); + + // Act & Assert - every enabled mock tool must exist in the snapshot + foreach (var mockTool in mockTools) + { + snapshotToolNames.Should().Contain( + mockTool.Name, + $"Mock tool '{mockTool.Name}' for server '{snapshot.ServerName}' " + + "does not exist in the real server snapshot. " + + "Verify the tool name against the real M365 MCP server."); + } + } + + /// + /// Resolves the repository root by walking up from the test assembly output directory + /// until a directory containing a src subdirectory is found. + /// + private static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "src"))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + + throw new InvalidOperationException( + "Could not locate the repository root. " + + "Ensure the test is running from within the repository directory tree."); + } + + private static string GetSnapshotsDirectory() + { + var repoRoot = GetRepoRoot(); + var snapshotsDir = Path.Combine( + repoRoot, "src", "Microsoft.Agents.A365.DevTools.MockToolingServer", "snapshots"); + + if (!Directory.Exists(snapshotsDir)) + { + throw new DirectoryNotFoundException( + $"Snapshots directory not found at: {snapshotsDir}"); + } + + return snapshotsDir; + } + + private static string GetMocksDirectory() + { + var repoRoot = GetRepoRoot(); + var mocksDir = Path.Combine( + repoRoot, "src", "Microsoft.Agents.A365.DevTools.MockToolingServer", "mocks"); + + if (!Directory.Exists(mocksDir)) + { + throw new DirectoryNotFoundException( + $"Mocks directory not found at: {mocksDir}"); + } + + return mocksDir; + } + + private static SnapshotFile LoadSnapshot(string snapshotFilePath) + { + var json = File.ReadAllText(snapshotFilePath); + var snapshot = JsonSerializer.Deserialize(json, JsonOptions); + + if (snapshot is null) + { + throw new InvalidOperationException( + $"Failed to deserialize snapshot file: {snapshotFilePath}"); + } + + return snapshot; + } + + private static List LoadEnabledMockTools(string serverName) + { + var mocksDir = GetMocksDirectory(); + var mockFilePath = Path.Combine(mocksDir, $"{serverName}.json"); + + if (!File.Exists(mockFilePath)) + { + throw new FileNotFoundException( + $"Mock definition file not found for server '{serverName}'. " + + $"Expected at: {mockFilePath}"); + } + + var json = File.ReadAllText(mockFilePath); + var allTools = JsonSerializer.Deserialize>(json, JsonOptions); + + if (allTools is null) + { + throw new InvalidOperationException( + $"Failed to deserialize mock file: {mockFilePath}"); + } + + return allTools.Where(t => t.Enabled).ToList(); + } + + /// + /// Minimal model for deserializing snapshot JSON files. + /// + private sealed class SnapshotFile + { + [JsonPropertyName("capturedAt")] + public string CapturedAt { get; set; } = string.Empty; + + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + [JsonPropertyName("tools")] + public List Tools { get; set; } = new(); + } + + /// + /// Minimal model for a tool entry within a snapshot file. + /// + private sealed class SnapshotTool + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs new file mode 100644 index 00000000..0c94b490 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.MockTools; + +/// +/// Integration tests that query live M365 MCP servers and verify tool catalogs +/// match the checked-in snapshot files. +/// +/// These tests are skipped unless MCP_BEARER_TOKEN is set. They are never +/// run in CI (which has no M365 credentials) — they are a developer tool for +/// detecting and refreshing snapshots when real servers change. +/// +/// Usage: +/// # Drift detection only (fails if live server differs from snapshot) +/// $env:MCP_BEARER_TOKEN = a365 develop get-token --output raw +/// dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +/// +/// # Drift detection + update snapshot files on disk +/// $env:MCP_UPDATE_SNAPSHOTS = "true" +/// dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +/// +[CollectionDefinition("MockToolSnapshotCapture", DisableParallelization = true)] +public class MockToolSnapshotCaptureCollection { } + +[Collection("MockToolSnapshotCapture")] +public class MockToolSnapshotCaptureTests +{ + private const string BearerTokenEnvVar = "MCP_BEARER_TOKEN"; + private const string UpdateSnapshotsEnvVar = "MCP_UPDATE_SNAPSHOTS"; + private const string McpBaseUrl = "https://substrate.office.com"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public static IEnumerable GetServerNames() + { + yield return new object[] { "mcp_CalendarTools" }; + yield return new object[] { "mcp_MailTools" }; + yield return new object[] { "mcp_MeServer" }; + yield return new object[] { "mcp_KnowledgeTools" }; + } + + /// + /// Queries each live M365 MCP server and compares its tool catalog against the + /// checked-in snapshot. Fails with a clear diff if the live server has added or + /// removed tools since the snapshot was captured. + /// + /// When MCP_UPDATE_SNAPSHOTS=true, writes refreshed snapshot files to disk + /// instead of asserting, so the caller can review and commit the changes. + /// + [Theory] + [MemberData(nameof(GetServerNames))] + public async Task LiveServer_ToolCatalog_ShouldMatchSnapshot(string serverName) + { + var token = Environment.GetEnvironmentVariable(BearerTokenEnvVar); + if (string.IsNullOrWhiteSpace(token)) + { + // No token — skip. Set MCP_BEARER_TOKEN to run these tests. + return; + } + + var liveTools = await FetchLiveToolsAsync(serverName, token); + + var shouldUpdate = string.Equals( + Environment.GetEnvironmentVariable(UpdateSnapshotsEnvVar), + "true", + StringComparison.OrdinalIgnoreCase); + + if (shouldUpdate) + { + WriteSnapshot(serverName, liveTools); + return; + } + + // Drift detection: compare live tool names against snapshot + var snapshot = LoadSnapshot(serverName); + + if (string.Equals(snapshot.CapturedAt, "UNPOPULATED", StringComparison.OrdinalIgnoreCase)) + { + // Snapshot never populated — write it now that we have a live token + WriteSnapshot(serverName, liveTools); + return; + } + + var snapshotNames = snapshot.Tools.Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + var liveNames = liveTools.Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + + var addedOnLive = liveNames.Except(snapshotNames).OrderBy(n => n).ToList(); + var removedFromLive = snapshotNames.Except(liveNames).OrderBy(n => n).ToList(); + + addedOnLive.Should().BeEmpty( + $"server '{serverName}' exposes new tools not yet captured in the snapshot: " + + $"{string.Join(", ", addedOnLive)}. " + + $"Re-run with {UpdateSnapshotsEnvVar}=true to refresh snapshots, " + + $"then update the corresponding mock file."); + + removedFromLive.Should().BeEmpty( + $"server '{serverName}' no longer exposes tools that are still in the snapshot: " + + $"{string.Join(", ", removedFromLive)}. " + + $"Re-run with {UpdateSnapshotsEnvVar}=true to refresh snapshots, " + + $"then remove the corresponding tools from the mock file."); + } + + // ------------------------------------------------------------------------- + // HTTP / SSE helpers + // ------------------------------------------------------------------------- + + private static async Task> FetchLiveToolsAsync(string serverName, string token) + { + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); + client.DefaultRequestHeaders.Add("Accept", "application/json, text/event-stream"); + + var requestBody = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 1, + method = "tools/list", + @params = new { } + }); + + var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{McpBaseUrl}/agents/servers/{serverName}", content); + + response.EnsureSuccessStatusCode(); + + var rawContent = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; + + // Real M365 MCP servers respond with SSE (text/event-stream). + // Take the last data: payload whose content starts with "{" — this is + // the JSON-RPC result frame. Earlier data: events (ping, endpoint) are + // discarded. + string jsonText; + if (contentType.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase)) + { + jsonText = rawContent.Split('\n') + .Where(line => line.StartsWith("data:", StringComparison.Ordinal)) + .Select(line => line["data:".Length..].Trim()) + .LastOrDefault(s => s.StartsWith("{", StringComparison.Ordinal)) + ?? string.Empty; + } + else + { + jsonText = rawContent; + } + + if (string.IsNullOrWhiteSpace(jsonText)) + throw new InvalidOperationException( + $"Server '{serverName}' returned an empty response body."); + + var rpcResponse = JsonSerializer.Deserialize(jsonText, JsonOptions) + ?? throw new InvalidOperationException( + $"Failed to deserialize JSON-RPC response from '{serverName}'."); + + if (rpcResponse.Error is not null) + throw new InvalidOperationException( + $"JSON-RPC error from '{serverName}': {rpcResponse.Error.Message}"); + + return rpcResponse.Result?.Tools ?? []; + } + + // ------------------------------------------------------------------------- + // Snapshot read / write helpers + // ------------------------------------------------------------------------- + + private static SnapshotFile LoadSnapshot(string serverName) + { + var path = Path.Combine(GetSnapshotsDirectory(), $"{serverName}.snapshot.json"); + + if (!File.Exists(path)) + throw new FileNotFoundException( + $"Snapshot file not found for server '{serverName}'. Expected at: {path}"); + + var json = File.ReadAllText(path); + var snapshot = JsonSerializer.Deserialize(json, JsonOptions); + + return snapshot ?? throw new InvalidOperationException( + $"Failed to deserialize snapshot file: {path}"); + } + + private static void WriteSnapshot(string serverName, List tools) + { + var dict = new Dictionary + { + ["$schema"] = "mock-snapshot-schema", + ["capturedAt"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + ["serverName"] = serverName, + ["tools"] = tools.Select(t => new { t.Name, t.Description, t.InputSchema }).ToList() + }; + + var json = JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }); + var outPath = Path.Combine(GetSnapshotsDirectory(), $"{serverName}.snapshot.json"); + + var utf8NoBom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(outPath, json, utf8NoBom); + } + + private static string GetSnapshotsDirectory() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "src"))) + { + return Path.Combine( + dir.FullName, + "src", + "Microsoft.Agents.A365.DevTools.MockToolingServer", + "snapshots"); + } + + dir = dir.Parent; + } + + throw new InvalidOperationException( + "Could not locate the repository root. " + + "Ensure the test is running from within the repository directory tree."); + } + + // ------------------------------------------------------------------------- + // Private models + // ------------------------------------------------------------------------- + + private sealed class SnapshotFile + { + [JsonPropertyName("capturedAt")] + public string CapturedAt { get; set; } = string.Empty; + + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + [JsonPropertyName("tools")] + public List Tools { get; set; } = []; + } + + private sealed class SnapshotTool + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + + private sealed class LiveTool + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("inputSchema")] + public JsonElement InputSchema { get; set; } + } + + private sealed class JsonRpcResponse + { + [JsonPropertyName("result")] + public JsonRpcResult? Result { get; set; } + + [JsonPropertyName("error")] + public JsonRpcError? Error { get; set; } + } + + private sealed class JsonRpcResult + { + [JsonPropertyName("tools")] + public List Tools { get; set; } = []; + } + + private sealed class JsonRpcError + { + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } +}