From bfaec7604361680687db67002ee9b0fb0fff248f Mon Sep 17 00:00:00 2001 From: Pratap Ladhani Date: Sat, 28 Feb 2026 14:52:59 -0800 Subject: [PATCH 1/2] fix: correct mock MCP tool names to match real M365 server contracts All four mock tool definition files had drifted from the tool catalogs exposed by real M365 MCP servers, causing agents developed against the mocks to encounter tool-not-found errors when switched to real servers. Mock corrections: - CalendarTools: rename 9 tools camelCase->PascalCase, remove 3 phantom tools (getEvent, getOrganization, getSchedule), add 4 missing tools (TentativelyAcceptEvent, ForwardEvent, GetUserDateAndTimeZoneSettings, GetRooms) - MailTools: strip Async suffix from all 20 tool names, add FlagEmail - MeServer: rename all 5 tools to match real server names - KnowledgeTools: replace 3 disabled placeholders with 5 real tools Fidelity infrastructure to prevent future drift: - snapshots/*.snapshot.json: authoritative tool catalogs captured from live M365 MCP servers (contract layer) - MockToolFidelityTests: CI gate asserting bidirectional coverage between mocks and snapshots (no credentials needed) - MockToolSnapshotCaptureTests: developer tool for drift detection and snapshot refresh (requires MCP_BEARER_TOKEN env var) Fixes microsoft/Agent365-devTools#300 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../README.md | 72 +- .../design.md | 38 + .../mocks/mcp_CalendarTools.json | 938 ++++++++---------- .../mocks/mcp_KnowledgeTools.json | 158 +++ .../mocks/mcp_MailTools.json | 72 +- .../mocks/mcp_MeServer.json | 11 +- .../snapshots/README.md | 86 ++ .../snapshots/mcp_CalendarTools.snapshot.json | 605 +++++++++++ .../mcp_KnowledgeTools.snapshot.json | 138 +++ .../snapshots/mcp_MailTools.snapshot.json | 810 +++++++++++++++ .../snapshots/mcp_MeServer.snapshot.json | 129 +++ .../MockTools/MockToolFidelityTests.cs | 221 +++++ .../MockTools/MockToolSnapshotCaptureTests.cs | 286 ++++++ 13 files changed, 2997 insertions(+), 567 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_KnowledgeTools.json create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_CalendarTools.snapshot.json create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_KnowledgeTools.snapshot.json create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MailTools.snapshot.json create mode 100644 src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/mcp_MeServer.snapshot.json create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs 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; + } +} From e85396e35fa862c61f40f5fdf28d756943528371 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Thu, 5 Mar 2026 16:11:10 -0800 Subject: [PATCH 2/2] fix: enforce inputSchema fidelity in CI and auto-sync mocks on snapshot refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend MockToolFidelityTests to validate inputSchema required fields and property names (not just tool names) against snapshots — CI-enforced - Add MergeMockFile to MockToolSnapshotCaptureTests so MCP_UPDATE_SNAPSHOTS=true refreshes both snapshot and mock files in one step; preserves existing responseTemplate/delayMs/errorRate for unchanged tools, adds new tools with defaults, marks removed tools as enabled=false for review - Fix pre-existing mock schema drift detected by the new fidelity tests: CalendarTools: CreateEvent, UpdateEvent, DeleteEventById, FindMeetingTimes, AcceptEvent, DeclineEvent, CancelEvent (remove OData-style userId path param, replace flat scalar fields for start/end/attendees) MailTools: GetMessage (add bodyPreviewOnly property) MeServer: GetMyDetails (add expand), GetMultipleUsersDetails (replace search/ filter with searchValues array, add required), GetManagerDetails (add select, fix required), GetDirectReportsDetails (replace top with select, fix required) - Update README files to document auto-merge workflow and inputSchema enforcement Co-Authored-By: Claude Sonnet 4.6 --- .../README.md | 10 +- .../mocks/mcp_CalendarTools.json | 764 ++++++------------ .../mocks/mcp_MailTools.json | 34 +- .../mocks/mcp_MeServer.json | 78 +- .../snapshots/README.md | 13 +- .../MockTools/MockToolFidelityTests.cs | 103 ++- .../MockTools/MockToolSnapshotCaptureTests.cs | 115 ++- 7 files changed, 531 insertions(+), 586 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md index e42953be..70de49f2 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md @@ -39,7 +39,9 @@ Tools for email operations including `SendEmail`, `SendEmailWithAttachments`, an ### 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. +Every tool exposed by a real M365 MCP server is present in the corresponding mock with the same name, same casing, same required input fields, and the same set of input property names. This ensures that agents developed against the mock will not encounter missing-tool or schema-mismatch errors when switched to a real server. + +The fidelity contract is CI-enforced: `MockToolFidelityTests` compares each mock tool's `inputSchema` (required fields and property names) against the corresponding snapshot. ### What the mock does not guarantee @@ -84,13 +86,15 @@ MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnaps $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: +2. Run the snapshot capture tests. This refreshes both the snapshot files **and** the mock files in one step: ```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. + The mock auto-merge preserves existing `responseTemplate` / `delayMs` / `errorRate` values for unchanged tools, adds new tools with sensible defaults, and marks removed tools as `enabled=false` for review. + +3. Review the diff — no manual schema editing is required. Check `responseTemplate` for any newly added tools and customise if your agent tests need specific data shapes. 4. Run fidelity tests to confirm coverage: 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 67005904..f5c31365 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_CalendarTools.json @@ -1,47 +1,51 @@ [ { "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.", + "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": { - "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.", - "x-ms-location": "path", - "x-ms-path": "userId" - }, "startDateTime": { "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": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00Z'). Default : user'sCurrentTime." }, "endDateTime": { "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": "End date/time in ISO 8601 format (e.g., '2026-01-15T17:00:00Z'). Default : currentTime + 90 days." }, - "top": { - "type": "integer", - "description": "The maximum number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" + "meetingTitle": { + "type": "string", + "description": "Meeting title to search by" + }, + "attendeeEmails": { + "type": "object", + "properties": { + "Item": { + "type": "string", + "description": "" + } + }, + "required": [] }, - "filter": { + "timeZone": { "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" + "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": "OData order by query to sort events.", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "description": "Property name to sort results by (e.g., 'displayName', 'jobTitle'). Meeting startTime is specified if not used." } - } + }, + "required": [] }, "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, @@ -51,44 +55,46 @@ }, { "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.", + "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": { - "userId": { + "userIdentifier": { "type": "string", - "description": "", - "x-ms-location": "path", - "x-ms-path": "userId" + "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 of the time range (ISO 8601). Should be today / after today.", - "x-ms-location": "query", - "x-ms-path": "startDateTime" + "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 of the time range (ISO 8601).should be after startDateTime.", - "x-ms-location": "query", - "x-ms-path": "endDateTime" + "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": "integer", - "description": "Max number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" + "type": "object", + "properties": {}, + "required": [] }, "orderby": { "type": "string", - "description": "Order by clause (e.g. start/dateTime).", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "description": "Property name to sort results by (e.g., 'start/dateTime'). Default is 'start/dateTime'." } }, "required": [ - "startDateTime", - "endDateTime" + "userIdentifier" ] }, "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\"}]}", @@ -99,204 +105,99 @@ }, { "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.\"", + "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": { - "userId": { + "subject": { "type": "string", - "description": "Use current user if no organizer is specified.If not, get the organizer's user principal name first, and use that value.'", - "x-ms-location": "path", - "x-ms-path": "userId" + "description": "Event title/subject" }, - "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": "Non-empty event subject/title. Avoid trailing whitespace and control characters.", - "x-ms-location": "body", - "x-ms-path": "subject", - "minLength": 1 + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00')" }, - "body": { - "type": "object", - "description": "Event body/description content. Use 'html' for rich formatting or 'text' for plain.", - "x-ms-location": "body", - "x-ms-path": "body", - "properties": { - "contentType": { - "type": "string", - "enum": [ - "Text", - "HTML" - ], - "description": "Body content type. If unsure, use 'Text'.", - "x-ms-location": "body", - "x-ms-path": "body.contentType" - }, - "content": { - "type": "string", - "description": "Body content string. For 'html', provide well-formed HTML; for 'text', provide plain UTF-8 text.", - "x-ms-location": "body", - "x-ms-path": "body.content" - } - }, - "required": [ - "contentType", - "content" - ] + "endDateTime": { + "type": "string", + "description": "End date/time in ISO 8601 format (e.g., '2026-01-15T09:30:00')" }, - "start": { - "type": "object", - "description": "Event start timestamp with time zone. Must be today or after.", - "x-ms-location": "body", - "x-ms-path": "start", - "properties": { - "dateTime": { - "type": "string", - "description": "Event start time in ISO 8601 without offset: 'yyyy-MM-ddTHH:mm:ss' (e.g., '2025-09-18T09:00:00').", - "x-ms-location": "body", - "x-ms-path": "start.dateTime", - "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" - }, - "timeZone": { - "type": "string", - "description": "Time zone identifier for start time. Use the system time zone if known.", - "x-ms-location": "body", - "x-ms-path": "start.timeZone" - } - }, - "required": [ - "dateTime", - "timeZone" - ] + "timeZone": { + "type": "string", + "description": "Time zone for the event (e.g., 'Pacific Standard Time'). Default is current user's timezone." }, - "end": { - "type": "object", - "description": "Event end timestamp with time zone. Must be after 'start'.", - "x-ms-location": "body", - "x-ms-path": "end", - "properties": { - "dateTime": { - "type": "string", - "description": "Event end time in ISO 8601 without offset: 'yyyy-MM-ddTHH:mm:ss' (e.g., '2025-09-18T10:00:00').", - "x-ms-location": "body", - "x-ms-path": "end.dateTime", - "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" - }, - "timeZone": { - "type": "string", - "description": "Time zone identifier of 'dateTime'. Use the same timeZone as 'start.timeZone' for consistency.", - "x-ms-location": "body", - "x-ms-path": "end.timeZone" - } - }, - "required": [ - "dateTime", - "timeZone" - ] + "bodyContent": { + "type": "string", + "description": "Event body/description content" + }, + "bodyContentType": { + "type": "string", + "description": "Body content type: 'Text' or 'HTML'. Default is 'Text'." }, "location": { - "type": "object", - "description": "Primary location for the event. Use a concise name (e.g., room name, address, or 'Microsoft Teams Meeting').If not specified, default to 'Microsoft Team Meeting'", - "x-ms-location": "body", - "x-ms-path": "location", - "properties": { - "displayName": { - "type": "string", - "description": "Human-readable location label (e.g., 'Conf Room 12A', '1 Microsoft Way, Redmond', or 'Teams Meeting').", - "x-ms-location": "body", - "x-ms-path": "location.displayName" - } - } + "type": "string", + "description": "Event location display name" }, - "attendees_addresses": { - "type": "array", - "description": "Email addresses of 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,}$" - } + "isOnlineMeeting": { + "type": "boolean", + "description": "Whether to create as an online meeting. Defaults to true." }, - "attendees_types": { - "type": "array", - "description": "Attendee roles 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" - ] - } + "onlineMeetingProvider": { + "type": "string", + "description": "Online meeting provider: 'teamsForBusiness', 'skypeForBusiness', 'skypeForConsumer'. Defaults to 'teamsForBusiness'." }, - "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" - } + "allowNewTimeProposals": { + "type": "object", + "properties": {}, + "required": [] }, "recurrence": { "type": "object", - "description": "Recurrence specification. Provide both 'pattern' and 'range'. Omit for one-time events.", - "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" + "description": "" }, "interval": { "type": "integer", - "description": "The time interval between occurrences (e.g., every 2 weeks)" + "description": "" }, "daysOfWeek": { "type": "array", + "description": "", "items": { - "type": "string", - "enum": [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday" - ] - }, - "description": "Days of the week for recurrence, if applicable" + "type": "string" + } }, "dayOfMonth": { - "type": "integer", - "description": "Day of the month for monthly pattern" + "type": "object", + "properties": {}, + "required": [] }, "month": { - "type": "integer", - "description": "Month for yearly pattern" + "type": "object", + "properties": {}, + "required": [] + }, + "index": { + "type": "string", + "description": "" + }, + "firstDayOfWeek": { + "type": "string", + "description": "" } }, "required": [ - "type", "interval" ] }, @@ -305,72 +206,49 @@ "properties": { "type": { "type": "string", - "enum": [ - "endDate", - "noEnd", - "numbered" - ], - "description": "The recurrence range type" + "description": "" }, "startDate": { "type": "string", - "description": "The start date of the recurrence (yyyy-MM-dd)" + "description": "" }, "endDate": { "type": "string", - "description": "The end date of the recurrence (yyyy-MM-dd)" + "description": "" }, "numberOfOccurrences": { - "type": "integer", - "description": "The number of times the event occurs" + "type": "object", + "properties": {}, + "required": [] } }, - "required": [ - "type", - "startDate" - ] + "required": [] } }, - "required": [ - "pattern", - "range" - ] - }, - "allowNewTimeProposals": { - "type": "boolean", - "description": "Whether invitees can propose a new time. Defaults to true if omitted.", - "x-ms-location": "body", - "x-ms-path": "allowNewTimeProposals" + "required": [] }, - "transactionId": { + "importance": { "type": "string", - "description": "Optional unique client-provided identifier to ensure idempotence", - "x-ms-location": "body", - "x-ms-path": "transactionId" + "description": "Event importance: 'low', 'normal', 'high'. Default is 'normal'." }, - "isOnlineMeeting": { - "type": "boolean", - "description": "Set to true to create an online meeting. Defaults to false.", - "x-ms-location": "body", - "x-ms-path": "isOnlineMeeting" + "sensitivity": { + "type": "string", + "description": "Event sensitivity: 'normal', 'personal', 'private', 'confidential'. Default is 'normal'." }, - "onlineMeetingProvider": { + "showAs": { "type": "string", - "enum": [ - "teamsForBusiness", - "skypeForBusiness", - "skypeForConsumer" - ], - "description": "Online meeting provider. REQUIRED when 'isOnlineMeeting' = true; omit otherwise.", - "x-ms-location": "body", - "x-ms-path": "onlineMeetingProvider" + "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", - "start", - "end", - "attendees_addresses" + "attendeeEmails", + "startDateTime", + "endDateTime" ] }, "responseTemplate": "Event '{{subject}}' created successfully (mock).", @@ -381,227 +259,138 @@ }, { "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.", + "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": { - "userId": { - "type": "string", - "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 unique identifier of the event to update.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to update" }, "subject": { "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" - } - } + "description": "Updated event title/subject" }, - "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" - } - } + "startDateTime": { + "type": "string", + "description": "Updated start date/time in ISO 8601 format" }, - "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" - } - } + "endDateTime": { + "type": "string", + "description": "Updated end date/time in ISO 8601 format" }, - "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,}$" - } + "timeZone": { + "type": "string", + "description": "Updated Time zone for the event (e.g., 'Pacific Standard Time')." }, - "attendees_types": { + "attendeesToAdd": { "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", + "description": "List of attendee email addresses or names to add", "items": { - "type": "string", - "enum": [ - "required", - "optional", - "resource" - ] + "type": "string" } }, - "attendees_names": { + "attendeesToRemove": { "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 attendee email addresses or names to remove", "items": { "type": "string" } }, - "isCancelled": { - "type": "boolean", - "description": "Set to true to cancel the event.", - "x-ms-location": "body", - "x-ms-path": "isCancelled" + "body": { + "type": "string", + "description": "Updated event body/description" + }, + "location": { + "type": "string", + "description": "Updated event location" }, "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." + "description": "" }, "interval": { "type": "integer", - "description": "The interval between occurrences." + "description": "" }, "daysOfWeek": { "type": "array", + "description": "", "items": { - "type": "string", - "enum": [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday" - ] - }, - "description": "The days of the week for the recurrence." + "type": "string" + } }, "dayOfMonth": { - "type": "integer", - "description": "The day of the month for the recurrence." + "type": "object", + "properties": {}, + "required": [] }, "month": { - "type": "integer", - "description": "The month for the yearly pattern." + "type": "object", + "properties": {}, + "required": [] + }, + "index": { + "type": "string", + "description": "" + }, + "firstDayOfWeek": { + "type": "string", + "description": "" } - } + }, + "required": [ + "interval" + ] }, "range": { "type": "object", "properties": { "type": { "type": "string", - "enum": [ - "endDate", - "noEnd", - "numbered" - ], - "description": "The recurrence range type. Default duration is 6 months." + "description": "" }, "startDate": { "type": "string", - "description": "The date to start the recurrence (yyyy-mm-dd). Should be today / after today." + "description": "" }, "endDate": { "type": "string", - "description": "The date to end the recurrence (yyyy-mm-dd). should be after startDate" + "description": "" }, "numberOfOccurrences": { - "type": "integer", - "description": "The number of times to repeat." + "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": [ @@ -616,21 +405,13 @@ }, { "name": "DeleteEventById", - "description": "Delete an event from a specified user's calendar.", + "description": "Delete a calendar event. Retrieve the event details first to extract the ID, then pass it to be deleted.", "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" - }, "eventId": { "type": "string", - "description": "The unique identifier of the event to delete.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to delete." } }, "required": [ @@ -645,76 +426,57 @@ }, { "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.", + "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": { - "userId": { + "userIdentifier": { "type": "string", - "description": "The ID or userPrincipalName of the organizer.", - "x-ms-location": "path", - "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,}$" - } + "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." }, - "attendees_types": { + "attendeeEmails": { "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", + "description": "List of attendee email addresses or names", "items": { "type": "string" } }, "meetingDuration": { "type": "string", - "description": "Meeting duration (e.g. 'PT1H').", - "x-ms-location": "body", - "x-ms-path": "meetingDuration" + "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": "integer" + "type": "object", + "properties": {}, + "required": [] }, "isOrganizerOptional": { - "type": "boolean" + "type": "boolean", + "description": "Whether the organizer's attendance is optional. Default is false." }, "returnSuggestionReasons": { - "type": "boolean" + "type": "boolean", + "description": "Whether to return reasons for each suggestion. Default is true." }, "minimumAttendeePercentage": { - "type": "number", - "format": "double" + "type": "object", + "properties": {}, + "required": [] } }, - "required": [ - "meetingDuration" - ] + "required": [] }, "responseTemplate": "Meeting time suggestions found for duration {{meetingDuration}} (mock).", "delayMs": 250, @@ -724,33 +486,21 @@ }, { "name": "AcceptEvent", - "description": "Accept the specified event invitation in a user's calendar.", + "description": "Accept a calendar event invitation. Optionally include a comment with the acceptance.", "inputSchema": { "type": "object", "properties": { - "userId": { - "type": "string", - "description": "The ID or userPrincipalName of the user.", - "x-ms-location": "path", - "x-ms-path": "userId" - }, "eventId": { "type": "string", - "description": "The ID of the event to accept.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to accept" }, "comment": { "type": "string", - "description": "Optional text included in the response.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "Optional comment to include with the acceptance" }, "sendResponse": { "type": "boolean", - "description": "Whether to send a response to the organizer.", - "x-ms-location": "body", - "x-ms-path": "sendResponse" + "description": "Whether to send a response to the organizer. Default is true." } }, "required": [ @@ -794,33 +544,21 @@ }, { "name": "DeclineEvent", - "description": "Decline the specified event invitation in a user's calendar.", + "description": "Decline a calendar event invitation. Optionally include a comment with the decline.", "inputSchema": { "type": "object", "properties": { - "userId": { - "type": "string", - "description": "The ID or userPrincipalName of the user.", - "x-ms-location": "path", - "x-ms-path": "userId" - }, "eventId": { "type": "string", - "description": "The ID of the event to decline.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to decline" }, "comment": { "type": "string", - "description": "Optional text included in the response.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "Optional comment to include with the decline" }, "sendResponse": { "type": "boolean", - "description": "Whether to send a response to the organizer.", - "x-ms-location": "body", - "x-ms-path": "sendResponse" + "description": "Whether to send a response to the organizer. Defaults to true." } }, "required": [ @@ -835,27 +573,17 @@ }, { "name": "CancelEvent", - "description": "Cancel an event in a specified user's calendar and notify attendees.", + "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": { - "userId": { - "type": "string", - "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 cancel.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to cancel" }, "comment": { "type": "string", - "description": "Optional message to include in the cancellation notification to attendees.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "Optional comment to include with the cancellation" } }, "required": [ 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 27ee605f..c018db8e 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MailTools.json @@ -249,6 +249,10 @@ "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": [ @@ -479,34 +483,24 @@ }, { "name": "SearchMessages", - "description": "Search Outlook messages using Microsoft Graph Search API with KQL-style queries.", + "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": { - "queryString": { + "message": { "type": "string", - "description": "KQL-style search string (e.g., 'contoso OR from:user@example.com')" - }, - "from": { - "type": "integer", - "description": "Zero-based offset of the first result" - }, - "size": { - "type": "integer", - "description": "Page size (1-50)" - }, - "enableTopResults": { - "type": "boolean", - "description": "If true, returns top results with relevance boosting" + "description": "Natural language search query for finding emails (e.g., 'emails from John about the project', 'unread messages from last week')" }, - "includeBody": { - "type": "boolean", - "description": "If true, include full body HTML content for each message (larger payload). BodyLength and BodyTruncated metadata returned." + "conversationId": { + "type": "string", + "description": "Existing conversation id (GUID). Auto-created if missing." } }, - "required": [] + "required": [ + "message" + ] }, - "responseTemplate": "Message search completed with query '{{queryString}}' (mock).", + "responseTemplate": "Message search completed for '{{message}}' (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, 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 26a7d8cb..f131d9ee 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/mocks/mcp_MeServer.json @@ -8,6 +8,10 @@ "select": { "type": "string", "description": "Comma-separated list of properties to return (e.g., 'displayName,mail,jobTitle'). If not specified, returns default properties." + }, + "expand": { + "type": "string", + "description": "Expand related entities" } }, "required": [] @@ -20,32 +24,42 @@ }, { "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.", + "description": "Search for multiple users in the directory by name, job title, office location, or other properties.", "inputSchema": { "type": "object", "properties": { - "search": { - "type": "string", - "description": "Search string to find users. Use format like '\"displayName:John\"' to search by display name, or '\"mail:john@\"' to search by email. Supports partial matching." + "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" + } }, - "filter": { + "propertyToSearchBy": { "type": "string", - "description": "OData filter expression (e.g., \"startsWith(displayName,'John')\" or \"mail eq 'john@contoso.com'\")." + "description": "User property to search (e.g., 'displayName', 'jobTitle', 'officeLocation', 'userPrincipalName', 'id')." }, "select": { "type": "string", - "description": "Comma-separated list of properties to return (e.g., 'displayName,mail,id')." + "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": "integer", - "description": "Maximum number of results to return. Default is 10, maximum is 999." + "type": "object", + "properties": {}, + "required": [] }, "orderby": { "type": "string", - "description": "Property to sort results by (e.g., 'displayName asc')." + "description": "Property name to sort results by (e.g., 'displayName', 'jobTitle')" } }, - "required": [] + "required": [ + "searchValues" + ] }, "responseTemplate": "{\"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#users\", \"value\": [{\"id\": \"mock-user-{{search}}\", \"displayName\": \"{{search}} (Mock User)\", \"mail\": \"{{search}}@contoso.com\", \"userPrincipalName\": \"{{search}}@contoso.com\", \"jobTitle\": \"Team Member\", \"officeLocation\": \"Remote\"}]}", "delayMs": 150, @@ -55,22 +69,26 @@ }, { "name": "GetUserDetails", - "description": "Get the profile of a specific user by their ID or userPrincipalName.", + "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": { - "userId": { + "userIdentifier": { "type": "string", - "description": "The unique identifier (GUID) or userPrincipalName (email) of the user to retrieve." + "description": "The user's name or object ID (GUID) or userPrincipalName (email-like UPN)." }, "select": { "type": "string", - "description": "Comma-separated list of properties to return." + "description": "Always pass in comma-separated list of properties you need" + }, + "expand": { + "type": "string", + "description": "Expand a related entity for the user" } }, - "required": ["userId"] + "required": ["userIdentifier"] }, - "responseTemplate": "{\"id\": \"{{userId}}\", \"displayName\": \"{{userId}} (Mock User)\", \"mail\": \"{{userId}}\", \"userPrincipalName\": \"{{userId}}\", \"jobTitle\": \"Team Member\", \"officeLocation\": \"Building 1\"}", + "responseTemplate": "{\"id\": \"{{userIdentifier}}\", \"displayName\": \"{{userIdentifier}} (Mock User)\", \"mail\": \"{{userIdentifier}}\", \"userPrincipalName\": \"{{userIdentifier}}\", \"jobTitle\": \"Team Member\", \"officeLocation\": \"Building 1\"}", "delayMs": 100, "errorRate": 0, "statusCode": 200, @@ -78,16 +96,22 @@ }, { "name": "GetManagerDetails", - "description": "Get the manager of a specific user or the currently signed-in user.", + "description": "Get a user's manager information - name, email, job title, etc.,", "inputSchema": { "type": "object", "properties": { "userId": { "type": "string", - "description": "The unique identifier or userPrincipalName of the user. Use 'me' for the currently signed-in user." + "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": [] + "required": [ + "userId" + ] }, "responseTemplate": "{\"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#directoryObjects/$entity\", \"@odata.type\": \"#microsoft.graph.user\", \"id\": \"mock-manager-001\", \"displayName\": \"Mock Manager\", \"mail\": \"mock.manager@contoso.com\", \"userPrincipalName\": \"mock.manager@contoso.com\", \"jobTitle\": \"Engineering Manager\"}", "delayMs": 100, @@ -97,20 +121,22 @@ }, { "name": "GetDirectReportsDetails", - "description": "Get the direct reports of a specific user or the currently signed-in user.", + "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": "The unique identifier or userPrincipalName of the user. Use 'me' for the currently signed-in user." + "description": "Name of the user whose direct reports (organizational team members) to retrieve. Use \"me\" for current / signed-in user." }, - "top": { - "type": "integer", - "description": "Maximum number of results to return." + "select": { + "type": "string", + "description": "Always pass in comma-separated list of properties you need for each direct report" } }, - "required": [] + "required": [ + "userId" + ] }, "responseTemplate": "{\"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#directoryObjects\", \"value\": [{\"@odata.type\": \"#microsoft.graph.user\", \"id\": \"mock-report-001\", \"displayName\": \"Alice Johnson (Mock)\", \"mail\": \"alice.johnson@contoso.com\", \"jobTitle\": \"Software Engineer\"}, {\"@odata.type\": \"#microsoft.graph.user\", \"id\": \"mock-report-002\", \"displayName\": \"Bob Smith (Mock)\", \"mail\": \"bob.smith@contoso.com\", \"jobTitle\": \"Software Engineer\"}]}", "delayMs": 100, diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md index 50586714..a30fbb14 100644 --- a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md @@ -15,7 +15,6 @@ Each snapshot file follows this JSON structure: "$schema": "mock-snapshot-schema", "capturedAt": "", "serverName": "", - "sourceNote": "Run MockToolSnapshotCaptureTests with MCP_BEARER_TOKEN set to populate this file.", "tools": [ { "name": "", @@ -31,7 +30,6 @@ Each snapshot file follows this JSON structure: | `$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 @@ -69,13 +67,18 @@ dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" ### Refresh snapshot files -Writes updated snapshot files to disk for review and commit: +Writes updated snapshot files **and auto-updates the corresponding mock files** in `../mocks/`: ```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. +The mock auto-merge: +- **Existing tools**: `inputSchema` updated from snapshot; `responseTemplate`, `delayMs`, `errorRate`, `statusCode`, and `enabled` preserved from the current mock entry. +- **New tools**: added with schema from snapshot and sensible defaults (`responseTemplate` is auto-generated, `delayMs=250`, `enabled=true`). +- **Removed tools**: kept in the mock file with `enabled=false` for developer review — delete them explicitly once confirmed. + +After refreshing, run `MockToolFidelityTests` to confirm coverage, then review the diff +(especially `responseTemplate` for any new tools) before committing. ## UNPOPULATED Snapshots 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 index 892b8ca6..5ad5ec04 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs @@ -55,16 +55,108 @@ public void SnapshotTools_ShouldAllExistInMockDefinition(string snapshotFilePath $"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)); + var mockTools = LoadEnabledMockTools(snapshot.ServerName).ToList(); + var mockToolsByName = mockTools.ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase); - // Act & Assert - every snapshot tool must exist in the mock + // Act & Assert - every snapshot tool must exist in the mock and have a compatible inputSchema foreach (var snapshotTool in snapshot.Tools) { - mockToolNames.Should().Contain( + mockToolsByName.Should().ContainKey( snapshotTool.Name, $"Snapshot tool '{snapshotTool.Name}' for server '{snapshot.ServerName}' " + $"is missing from the mock definition. Add it to mocks/{snapshot.ServerName}.json."); + + if (!mockToolsByName.TryGetValue(snapshotTool.Name, out var mockTool)) + { + continue; + } + + if (TryGetInputSchema(snapshotTool.InputSchema, out var snapshotSchema)) + { + TryGetInputSchema(mockTool.InputSchema, out var mockSchema).Should().BeTrue( + $"Mock tool '{snapshotTool.Name}' for server '{snapshot.ServerName}' " + + "must define an inputSchema when the snapshot tool does."); + + GetSchemaRequiredAndPropertyNames(snapshotSchema, out var snapshotRequired, out var snapshotPropertyNames); + GetSchemaRequiredAndPropertyNames(mockSchema, out var mockRequired, out var mockPropertyNames); + + mockRequired.Should().BeEquivalentTo( + snapshotRequired, + $"Required fields for tool '{snapshotTool.Name}' on server '{snapshot.ServerName}' " + + "must match between snapshot and mock inputSchema."); + + mockPropertyNames.Should().BeEquivalentTo( + snapshotPropertyNames, + $"Property names for tool '{snapshotTool.Name}' on server '{snapshot.ServerName}' " + + "must match between snapshot and mock inputSchema."); + } + } + } + + private static bool TryGetInputSchema(object? value, out JsonElement inputSchema) + { + inputSchema = default; + + if (value is JsonElement element) + { + if (element.ValueKind == JsonValueKind.Undefined || element.ValueKind == JsonValueKind.Null) + { + return false; + } + + inputSchema = element.Clone(); + return true; + } + + if (value is string json && !string.IsNullOrWhiteSpace(json)) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + inputSchema = document.RootElement.Clone(); + return true; + } + + return false; + } + + private static void GetSchemaRequiredAndPropertyNames( + JsonElement schema, + out HashSet required, + out HashSet propertyNames) + { + required = new HashSet(StringComparer.Ordinal); + propertyNames = new HashSet(StringComparer.Ordinal); + + if (schema.ValueKind != JsonValueKind.Object) + { + return; + } + + if (schema.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var name = item.GetString(); + if (!string.IsNullOrEmpty(name)) + { + required.Add(name); + } + } + } + } + + if (schema.TryGetProperty("properties", out var propertiesElement) && + propertiesElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in propertiesElement.EnumerateObject()) + { + if (!string.IsNullOrEmpty(property.Name)) + { + propertyNames.Add(property.Name); + } + } } } @@ -217,5 +309,8 @@ private sealed class SnapshotTool [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + + [JsonPropertyName("inputSchema")] + public JsonElement InputSchema { get; set; } } } 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 index 0c94b490..1f2da709 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs @@ -4,7 +4,6 @@ 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; @@ -21,9 +20,17 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.MockTools; /// $env:MCP_BEARER_TOKEN = a365 develop get-token --output raw /// dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" /// -/// # Drift detection + update snapshot files on disk +/// # Refresh snapshots AND auto-update the corresponding mock files /// $env:MCP_UPDATE_SNAPSHOTS = "true" /// dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +/// +/// When MCP_UPDATE_SNAPSHOTS=true, both the snapshot file and the mock file are +/// written. The mock merge preserves existing responseTemplate / delayMs / errorRate +/// values for tools that still exist, adds new tools with generated defaults, and +/// sets enabled=false for tools that have been removed from the real server. +/// +/// Future: consider promoting snapshot capture and mock sync to explicit +/// a365 develop subcommands for better discoverability. /// [CollectionDefinition("MockToolSnapshotCapture", DisableParallelization = true)] public class MockToolSnapshotCaptureCollection { } @@ -101,14 +108,12 @@ public async Task LiveServer_ToolCatalog_ShouldMatchSnapshot(string serverName) 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."); + $"Re-run with {UpdateSnapshotsEnvVar}=true to refresh snapshots and auto-update the 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."); + $"Re-run with {UpdateSnapshotsEnvVar}=true to refresh snapshots and auto-update the mock file."); } // ------------------------------------------------------------------------- @@ -129,8 +134,8 @@ private static async Task> FetchLiveToolsAsync(string serverName, @params = new { } }); - var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); - var response = await client.PostAsync($"{McpBaseUrl}/agents/servers/{serverName}", content); + using var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); + using var response = await client.PostAsync($"{McpBaseUrl}/agents/servers/{serverName}", content); response.EnsureSuccessStatusCode(); @@ -204,9 +209,84 @@ private static void WriteSnapshot(string serverName, List tools) var utf8NoBom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); File.WriteAllText(outPath, json, utf8NoBom); + + // Auto-sync the mock file so its inputSchema stays aligned with the new snapshot. + // Existing responseTemplate / delayMs / errorRate / statusCode values are preserved. + MergeMockFile(serverName, tools); + } + + // ------------------------------------------------------------------------- + // Mock file auto-sync + // ------------------------------------------------------------------------- + + /// + /// Merges live tool definitions into the corresponding mock file: + /// + /// Existing tools: schema updated from snapshot; behavior fields (responseTemplate, + /// delayMs, errorRate, statusCode, enabled) preserved from the current mock entry. + /// New tools (in snapshot but not in mock): added with generated defaults. + /// Removed tools (in mock but not in snapshot): kept with enabled=false + /// so the developer can review and delete them explicitly. + /// + /// + private static void MergeMockFile(string serverName, List liveTools) + { + var mockPath = Path.Combine(GetMocksDirectory(), $"{serverName}.json"); + + // Load existing mock entries to preserve their behavior fields. + var existingByName = File.Exists(mockPath) + ? (JsonSerializer.Deserialize>(File.ReadAllText(mockPath), JsonOptions) ?? []) + .ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Rebuild from snapshot order, preserving behavior fields for existing tools. + var updated = liveTools.Select(live => + { + existingByName.TryGetValue(live.Name, out var ex); + return new Dictionary + { + ["name"] = live.Name, + ["description"] = live.Description, + ["inputSchema"] = live.InputSchema, + ["responseTemplate"] = ex?.ResponseTemplate ?? $"Mock response from {live.Name} (mock).", + ["delayMs"] = (object)(ex?.DelayMs ?? 250), + ["errorRate"] = (object)(ex?.ErrorRate ?? 0.0), + ["statusCode"] = (object)(ex?.StatusCode ?? 200), + ["enabled"] = (object)(ex?.Enabled ?? true), + }; + }).ToList(); + + // Tools removed from the real server: keep as disabled for developer review. + var liveNames = liveTools.Select(t => t.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ex in existingByName.Values.Where(e => !liveNames.Contains(e.Name))) + { + updated.Add(new Dictionary + { + ["name"] = ex.Name, + ["description"] = ex.Description, + ["inputSchema"] = ex.InputSchema, + ["responseTemplate"] = ex.ResponseTemplate, + ["delayMs"] = (object)ex.DelayMs, + ["errorRate"] = (object)ex.ErrorRate, + ["statusCode"] = (object)ex.StatusCode, + ["enabled"] = (object)false, + }); + } + + var utf8NoBom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText( + mockPath, + JsonSerializer.Serialize(updated, new JsonSerializerOptions { WriteIndented = true }), + utf8NoBom); } private static string GetSnapshotsDirectory() + => Path.Combine(GetMockToolingServerDirectory(), "snapshots"); + + private static string GetMocksDirectory() + => Path.Combine(GetMockToolingServerDirectory(), "mocks"); + + private static string GetMockToolingServerDirectory() { var dir = new DirectoryInfo(AppContext.BaseDirectory); @@ -217,8 +297,7 @@ private static string GetSnapshotsDirectory() return Path.Combine( dir.FullName, "src", - "Microsoft.Agents.A365.DevTools.MockToolingServer", - "snapshots"); + "Microsoft.Agents.A365.DevTools.MockToolingServer"); } dir = dir.Parent; @@ -283,4 +362,20 @@ private sealed class JsonRpcError [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; } + + /// + /// Minimal model for reading existing mock entries to preserve their behavior fields + /// during auto-merge. Only the fields that must survive a snapshot refresh are included. + /// + private sealed class ExistingMockEntry + { + [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; } + [JsonPropertyName("responseTemplate")] public string ResponseTemplate { get; set; } = string.Empty; + [JsonPropertyName("delayMs")] public int DelayMs { get; set; } = 250; + [JsonPropertyName("errorRate")] public double ErrorRate { get; set; } = 0.0; + [JsonPropertyName("statusCode")] public int StatusCode { get; set; } = 200; + [JsonPropertyName("enabled")] public bool Enabled { get; set; } = true; + } }