diff --git a/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/README.md index 059062e7..70de49f2 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,75 @@ 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, 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 + +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. This refreshes both the snapshot files **and** the mock files in one step: + + ```bash + MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" + ``` + + 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: + + ```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..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,280 +1,203 @@ [ { - "name": "acceptEvent", - "description": "Accept the specified event invitation in a user's calendar.", + "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": { - "userId": { + "startDateTime": { "type": "string", - "description": "The ID or userPrincipalName of the user.", - "x-ms-location": "path", - "x-ms-path": "userId" + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00Z'). Default : user'sCurrentTime." }, - "eventId": { + "endDateTime": { "type": "string", - "description": "The ID of the event to accept.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "End date/time in ISO 8601 format (e.g., '2026-01-15T17:00:00Z'). Default : currentTime + 90 days." }, - "comment": { + "meetingTitle": { "type": "string", - "description": "Optional text included in the response.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "Meeting title to search by" }, - "sendResponse": { - "type": "boolean", - "description": "Whether to send a response to the organizer.", - "x-ms-location": "body", - "x-ms-path": "sendResponse" + "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": [ - "eventId" - ] + "required": [] }, - "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 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": "The ID or userPrincipalName of the user who owns the event.", - "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." }, - "eventId": { + "startDateTime": { "type": "string", - "description": "The unique identifier of the event to cancel.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "Start date/time in ISO 8601 format (e.g., '2026-01-15T09:00:00'). Default: current time of current user" }, - "comment": { + "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": "Optional message to include in the cancellation notification to attendees.", - "x-ms-location": "body", - "x-ms-path": "comment" + "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": [ - "eventId" + "userIdentifier" ] }, - "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", - "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.\"", + "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": { - "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" ] }, @@ -283,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" - ] + "required": [] }, - "allowNewTimeProposals": { - "type": "boolean", - "description": "Whether invitees can propose a new time. Defaults to true if omitted.", - "x-ms-location": "body", - "x-ms-path": "allowNewTimeProposals" - }, - "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).", @@ -358,63 +258,160 @@ "enabled": true }, { - "name": "declineEvent", - "description": "Decline the specified event invitation in a user's calendar.", + "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": { - "userId": { + "eventId": { "type": "string", - "description": "The ID or userPrincipalName of the user.", - "x-ms-location": "path", - "x-ms-path": "userId" + "description": "The ID of the event to update" }, - "eventId": { + "subject": { "type": "string", - "description": "The ID of the event to decline.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "Updated event title/subject" }, - "comment": { + "startDateTime": { "type": "string", - "description": "Optional text included in the response.", - "x-ms-location": "body", - "x-ms-path": "comment" + "description": "Updated start date/time in ISO 8601 format" }, - "sendResponse": { - "type": "boolean", - "description": "Whether to send a response to the organizer.", - "x-ms-location": "body", - "x-ms-path": "sendResponse" + "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" ] }, - "responseTemplate": "Event '{{eventId}}' declined (mock).", + "responseTemplate": "Event '{{eventId}}' updated (mock).", "delayMs": 250, "errorRate": 0, "statusCode": 200, "enabled": true }, { - "name": "deleteEvent", - "description": "Delete an event from a specified user's calendar.", + "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": { - "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": [ @@ -428,164 +425,58 @@ "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.", + "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": { - "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" } }, - "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').", - "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." }, - "locationConstraint": { - "type": "object", - "description": "Options for meeting location.", - "x-ms-location": "body", - "x-ms-path": "locationConstraint", - "properties": { - "isRequired": { - "type": "boolean" - }, - "suggestLocation": { - "type": "boolean" - }, - "locations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "displayName": { - "type": "string" - }, - "locationEmailAddress": { - "type": "string" - }, - "resolveAvailability": { - "type": "boolean" - } - }, - "required": [ - "displayName" - ] - } - } - } + "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, @@ -594,480 +485,181 @@ "enabled": true }, { - "name": "getEvent", - "description": "Get a single calendar event from a specified user’s calendar.", + "name": "AcceptEvent", + "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 whose event is being retrieved.", - "x-ms-location": "path", - "x-ms-path": "userId" - }, "eventId": { "type": "string", - "description": "The unique identifier of the event.", - "x-ms-location": "path", - "x-ms-path": "eventId" + "description": "The ID of the event to accept" }, - "select": { + "comment": { "type": "string", - "description": "OData $select query parameter to specify returned properties.", - "x-ms-location": "query", - "x-ms-path": "$select" + "description": "Optional comment to include with the acceptance" }, - "expand": { - "type": "string", - "description": "OData $expand query parameter to include navigation properties like exceptionOccurrences.", - "x-ms-location": "query", - "x-ms-path": "$expand" + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Default is true." } }, "required": [ "eventId" ] }, - "responseTemplate": "Event '{{eventId}}' retrieved (mock).", + "responseTemplate": "Event '{{eventId}}' accepted (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": "TentativelyAcceptEvent", + "description": "Tentatively accept a calendar event invitation. Optionally include a comment with the tentative acceptance.", "inputSchema": { "type": "object", "properties": { - "organizationId": { + "eventId": { "type": "string", - "description": "The ID of the organization (tenant) to retrieve.", - "x-ms-location": "path", - "x-ms-path": "organizationId" + "description": "The ID of the event to tentatively accept" }, - "select": { + "comment": { "type": "string", - "description": "Comma-separated list of organization properties to return (via $select).", - "x-ms-location": "query", - "x-ms-path": "$select" + "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": [ - "organizationId" + "eventId" ] }, - "responseTemplate": "Organization '{{organizationId}}' retrieved (mock).", + "responseTemplate": "Event '{{eventId}}' tentatively accepted (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": "DeclineEvent", + "description": "Decline a calendar event invitation. Optionally include a comment with the decline.", "inputSchema": { "type": "object", "properties": { - "userId": { + "eventId": { "type": "string", - "description": "The ID or userPrincipalName of the user.", - "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" - } - }, - "startTime": { - "type": "object", - "description": "Start time for the query. Should be today / after today. Use system timezone if not specified. ", - "x-ms-location": "body", - "x-ms-path": "startTime", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] + "description": "The ID of the event to decline" }, - "endTime": { - "type": "object", - "description": "End time for the query.Should be after startTime. Use system timezone if not specified", - "x-ms-location": "body", - "x-ms-path": "endTime", - "properties": { - "dateTime": { - "type": "string" - }, - "timeZone": { - "type": "string" - } - }, - "required": [ - "dateTime", - "timeZone" - ] + "comment": { + "type": "string", + "description": "Optional comment to include with the decline" }, - "availabilityViewInterval": { - "type": "integer", - "description": "Time slot length in minutes." + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer. Defaults to true." } }, "required": [ - "schedules", - "startTime", - "endTime" + "eventId" ] }, - "responseTemplate": "Schedule retrieved for {{schedules.length}} users/resources (mock).", + "responseTemplate": "Event '{{eventId}}' declined (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": "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": { - "userId": { - "type": "string", - "description": "", - "x-ms-location": "path", - "x-ms-path": "userId" - }, - "startDateTime": { - "type": "string", - "description": "Start of the time range (ISO 8601). Should be today / after today.", - "x-ms-location": "query", - "x-ms-path": "startDateTime" - }, - "endDateTime": { + "eventId": { "type": "string", - "description": "End of the time range (ISO 8601).should be after startDateTime.", - "x-ms-location": "query", - "x-ms-path": "endDateTime" + "description": "The ID of the event to cancel" }, - "top": { - "type": "integer", - "description": "Max number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" - }, - "orderby": { + "comment": { "type": "string", - "description": "Order by clause (e.g. start/dateTime).", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "description": "Optional comment to include with the cancellation" } }, "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}}' cancelled (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": "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": { - "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" - }, - "endDateTime": { + "eventId": { "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" - }, - "top": { - "type": "integer", - "description": "The maximum number of events to return.", - "x-ms-location": "query", - "x-ms-path": "$top" + "description": "The ID of the event to forward" }, - "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" + "recipientEmails": { + "type": "array", + "description": "List of recipient email addresses or names to forward the event to", + "items": { + "type": "string" + } }, - "orderby": { + "comment": { "type": "string", - "description": "OData order by query to sort events.", - "x-ms-location": "query", - "x-ms-path": "$orderby" + "description": "Optional comment to include with the forwarded event" } - } + }, + "required": [ + "eventId", + "recipientEmails" + ] }, - "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}}' forwarded (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": "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": { - "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" - }, - "subject": { + "userIdentifier": { "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.", - "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" - } - }, - "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." - } - } - } - } + "description": "User identifier - can be email, Entra ID (GUID), display name, or 'me' for current user. Defaults to 'me' if not specified." } }, - "required": [ - "eventId" - ] + "required": [] }, - "responseTemplate": "Event '{{eventId}}' updated (mock).", + "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..c018db8e 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", @@ -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": [ @@ -262,7 +266,7 @@ "enabled": true }, { - "name": "UpdateMessageAsync", + "name": "UpdateMessage", "description": "Update a message's mutable properties (subject, body, categories, importance).", "inputSchema": { "type": "object", @@ -306,7 +310,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 +361,7 @@ "enabled": true }, { - "name": "ReplyToMessageAsync", + "name": "ReplyToMessage", "description": "Send a reply to an existing message.", "inputSchema": { "type": "object", @@ -377,7 +411,7 @@ "enabled": true }, { - "name": "ReplyAllToMessageAsync", + "name": "ReplyAllToMessage", "description": "Send a reply-all to an existing message.", "inputSchema": { "type": "object", @@ -427,7 +461,7 @@ "enabled": true }, { - "name": "SendDraftMessageAsync", + "name": "SendDraftMessage", "description": "Send an existing draft message by ID.", "inputSchema": { "type": "object", @@ -448,42 +482,32 @@ "enabled": true }, { - "name": "SearchMessagesAsync", - "description": "Search Outlook messages using Microsoft Graph Search API with KQL-style queries.", + "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": { - "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" + "description": "Natural language search query for finding emails (e.g., 'emails from John about the project', 'unread messages from last week')" }, - "size": { - "type": "integer", - "description": "Page size (1-50)" - }, - "enableTopResults": { - "type": "boolean", - "description": "If true, returns top results with relevance boosting" - }, - "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, "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 +528,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 +554,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 +589,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 +624,7 @@ "enabled": true }, { - "name": "DeleteAttachmentAsync", + "name": "DeleteAttachment", "description": "Delete an attachment from a message.", "inputSchema": { "type": "object", @@ -626,7 +650,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 +708,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 +762,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 +816,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 +898,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..f131d9ee 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", @@ -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": [] @@ -19,33 +23,43 @@ "enabled": true }, { - "name": "listUsers", - "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.", + "name": "GetMultipleUsersDetails", + "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, @@ -54,40 +68,50 @@ "enabled": true }, { - "name": "getUser", - "description": "Get the profile of a specific user by their ID or userPrincipalName.", + "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": { - "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, "enabled": true }, { - "name": "getManager", - "description": "Get the manager of a specific user or the currently signed-in user.", + "name": "GetManagerDetails", + "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, @@ -96,21 +120,23 @@ "enabled": true }, { - "name": "getDirectReports", - "description": "Get the direct reports of a specific user or the currently signed-in user.", + "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": "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, @@ -119,4 +145,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..a30fbb14 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.MockToolingServer/snapshots/README.md @@ -0,0 +1,89 @@ +# 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": "", + "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`). | +| `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 **and auto-updates the corresponding mock files** in `../mocks/`: +```bash +MCP_UPDATE_SNAPSHOTS=true dotnet test --filter "FullyQualifiedName~MockToolSnapshotCaptureTests" +``` + +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 + +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..5ad5ec04 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolFidelityTests.cs @@ -0,0 +1,316 @@ +// 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).ToList(); + var mockToolsByName = mockTools.ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase); + + // Act & Assert - every snapshot tool must exist in the mock and have a compatible inputSchema + foreach (var snapshotTool in snapshot.Tools) + { + 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); + } + } + } + } + + [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; + + [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 new file mode 100644 index 00000000..1f2da709 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/MockTools/MockToolSnapshotCaptureTests.cs @@ -0,0 +1,381 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; + +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" +/// +/// # 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 { } + +[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 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 and auto-update 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 { } + }); + + 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(); + + 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); + + // 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); + + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "src"))) + { + return Path.Combine( + dir.FullName, + "src", + "Microsoft.Agents.A365.DevTools.MockToolingServer"); + } + + 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; + } + + /// + /// 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; + } +}