From 45659ff777e1a63ef3398e0fcca0c49e4977659f Mon Sep 17 00:00:00 2001 From: Nathan Spady Date: Mon, 2 Mar 2026 11:08:07 -0800 Subject: [PATCH 1/2] Updated tools with proper annotations --- src/server.ts | 9 +++ .../unit/schemas/tool-registration.test.ts | 78 ++++++++++++++++++- .../server/GoogleCalendarMcpServer.test.ts | 13 ++++ src/tools/registry.ts | 50 +++++++++++- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index e1285f1..6d2e53b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { OAuth2Client } from "google-auth-library"; import { readFileSync } from "fs"; import { join, dirname } from "path"; @@ -133,6 +134,13 @@ export class GoogleCalendarMcpServer { }; const manageAccountsHandler = new ManageAccountsHandler(); + const manageAccountsAnnotations: ToolAnnotations = { + title: "Manage Connected Google Accounts", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + }; this.server.tool( 'manage-accounts', "Manage Google account authentication. Actions: 'list' (show accounts), 'add' (authenticate new account), 'remove' (remove account).", @@ -144,6 +152,7 @@ export class GoogleCalendarMcpServer { .optional() .describe("Account nickname (e.g., 'work', 'personal') - a friendly name to identify this Google account. Required for 'add' and 'remove'. Optional for 'list' (shows all if omitted)") }, + manageAccountsAnnotations, async (args) => { return manageAccountsHandler.runTool(args, serverContext); } diff --git a/src/tests/unit/schemas/tool-registration.test.ts b/src/tests/unit/schemas/tool-registration.test.ts index 93f454e..082f7ae 100644 --- a/src/tests/unit/schemas/tool-registration.test.ts +++ b/src/tests/unit/schemas/tool-registration.test.ts @@ -12,7 +12,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; describe('Tool Registration', () => { let mockServer: McpServer; - let registeredTools: Array<{ name: string; description: string; inputSchema: any }>; + let registeredTools: Array<{ + name: string; + title: string; + description: string; + annotations: Record; + inputSchema: any; + }>; beforeEach(() => { mockServer = new McpServer({ name: 'test', version: '1.0.0' }); @@ -22,7 +28,9 @@ describe('Tool Registration', () => { mockServer.registerTool = vi.fn((name: string, definition: any, _handler: any) => { registeredTools.push({ name, + title: definition.title, description: definition.description, + annotations: definition.annotations, inputSchema: definition.inputSchema }); // Return a mock RegisteredTool @@ -55,6 +63,72 @@ describe('Tool Registration', () => { } }); + it('should register titles and annotations for all tools', async () => { + await ToolRegistry.registerAll(mockServer, async () => ({ content: [] })); + + const expectedTitles: Record = { + 'list-calendars': 'List Calendars', + 'list-events': 'List Calendar Events', + 'search-events': 'Search Calendar Events', + 'get-event': 'Get Event Details', + 'list-colors': 'List Calendar Colors', + 'create-event': 'Create Calendar Event', + 'create-events': 'Create Calendar Events (Bulk)', + 'update-event': 'Update Calendar Event', + 'delete-event': 'Delete Calendar Event', + 'get-freebusy': 'Get Free/Busy', + 'get-current-time': 'Get Current Time', + 'respond-to-event': 'Respond to Event Invitation' + }; + + const expectedAnnotations: Record> = { + 'list-calendars': { readOnlyHint: true, openWorldHint: false }, + 'list-events': { readOnlyHint: true, openWorldHint: false }, + 'search-events': { readOnlyHint: true, openWorldHint: false }, + 'get-event': { readOnlyHint: true, openWorldHint: false }, + 'list-colors': { readOnlyHint: true, openWorldHint: false }, + 'get-freebusy': { readOnlyHint: true, openWorldHint: false }, + 'get-current-time': { readOnlyHint: true, openWorldHint: false }, + 'create-event': { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + }, + 'create-events': { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + }, + 'update-event': { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + }, + 'delete-event': { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + }, + 'respond-to-event': { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } + }; + + for (const tool of registeredTools) { + expect(tool.title).toBe(expectedTitles[tool.name]); + expect(tool.title.trim().length).toBeGreaterThan(0); + expect(tool.annotations).toEqual(expectedAnnotations[tool.name]); + expect(tool.annotations.openWorldHint).toBe(false); + } + }); + it('should have valid input schemas for all tools', async () => { await ToolRegistry.registerAll(mockServer, async () => ({ content: [] })); @@ -220,4 +294,4 @@ describe('Schema Extraction Edge Cases', () => { expect(extractedShape).toBeDefined(); expect(typeof extractedShape).toBe('object'); }); -}); \ No newline at end of file +}); diff --git a/src/tests/unit/server/GoogleCalendarMcpServer.test.ts b/src/tests/unit/server/GoogleCalendarMcpServer.test.ts index ad0e70d..1d71229 100644 --- a/src/tests/unit/server/GoogleCalendarMcpServer.test.ts +++ b/src/tests/unit/server/GoogleCalendarMcpServer.test.ts @@ -117,6 +117,19 @@ describe('GoogleCalendarMcpServer', () => { expect(state.tokenManagerLoadAllAccounts).toHaveBeenCalledTimes(1); expect(state.toolRegistryRegisterAll).toHaveBeenCalledTimes(1); expect(state.mcpServerInstance.tool).toHaveBeenCalledTimes(1); + expect(state.mcpServerInstance.tool).toHaveBeenCalledWith( + 'manage-accounts', + expect.any(String), + expect.any(Object), + { + title: 'Manage Google Accounts', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + }, + expect.any(Function) + ); expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); expect(server.getServer()).toBe(state.mcpServerInstance); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index f763ffa..592f5b1 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,4 +1,5 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { BaseToolHandler } from "../handlers/core/BaseToolHandler.js"; import { ALLOWED_EVENT_FIELDS } from "../utils/field-mask-builder.js"; @@ -767,12 +768,33 @@ export type RespondToEventInput = ToolInputs['respond-to-event']; interface ToolDefinition { name: keyof typeof ToolSchemas; + title: string; description: string; + annotations: ToolAnnotations; schema: z.ZodType; handler: new () => BaseToolHandler; handlerFunction?: (args: any) => Promise; } +const READ_ONLY_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: true, + openWorldHint: false +}; + +const WRITE_NON_DESTRUCTIVE_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false +}; + +const WRITE_DESTRUCTIVE_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false +}; + export class ToolRegistry { private static extractSchemaShape(schema: z.ZodType): any { @@ -790,13 +812,17 @@ export class ToolRegistry { private static tools: ToolDefinition[] = [ { name: "list-calendars", + title: "List Calendars", description: "List all available calendars", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['list-calendars'], handler: ListCalendarsHandler }, { name: "list-events", + title: "List Calendar Events", description: "List events from one or more calendars. Supports both calendar IDs and calendar names.", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['list-events'], handler: ListEventsHandler, handlerFunction: async (args: ListEventsInput & { calendarId: string | string[] }) => { @@ -863,61 +889,81 @@ export class ToolRegistry { }, { name: "search-events", + title: "Search Calendar Events", description: "Search for events in a calendar by text query.", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['search-events'], handler: SearchEventsHandler }, { name: "get-event", + title: "Get Event Details", description: "Get details of a specific event by ID.", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['get-event'], handler: GetEventHandler }, { name: "list-colors", + title: "List Calendar Colors", description: "List available color IDs and their meanings for calendar events", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['list-colors'], handler: ListColorsHandler }, { name: "create-event", + title: "Create Calendar Event", description: "Create a new calendar event.", + annotations: WRITE_NON_DESTRUCTIVE_ANNOTATIONS, schema: ToolSchemas['create-event'], handler: CreateEventHandler }, { name: "create-events", + title: "Create Calendar Events (Bulk)", description: "Create multiple calendar events in bulk. Accepts shared defaults (account, calendarId, timeZone) that apply to all events, with per-event overrides. Skips conflict and duplicate detection for speed.", + annotations: WRITE_NON_DESTRUCTIVE_ANNOTATIONS, schema: ToolSchemas['create-events'], handler: CreateEventsHandler }, { name: "update-event", + title: "Update Calendar Event", description: "Update an existing calendar event with recurring event modification scope support.", + annotations: WRITE_DESTRUCTIVE_ANNOTATIONS, schema: ToolSchemas['update-event'], handler: UpdateEventHandler }, { name: "delete-event", + title: "Delete Calendar Event", description: "Delete a calendar event.", + annotations: WRITE_DESTRUCTIVE_ANNOTATIONS, schema: ToolSchemas['delete-event'], handler: DeleteEventHandler }, { name: "get-freebusy", + title: "Get Free/Busy", description: "Query free/busy information for calendars. Note: Time range is limited to a maximum of 3 months between timeMin and timeMax.", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['get-freebusy'], handler: FreeBusyEventHandler }, { name: "get-current-time", + title: "Get Current Time", description: "Get the current date and time. Call this FIRST before creating, updating, or searching for events to ensure you have accurate date context for scheduling.", + annotations: READ_ONLY_ANNOTATIONS, schema: ToolSchemas['get-current-time'], handler: GetCurrentTimeHandler }, { name: "respond-to-event", + title: "Respond to Event Invitation", description: "Respond to a calendar event invitation with Accept, Decline, Maybe (Tentative), or No Response.", + annotations: WRITE_NON_DESTRUCTIVE_ANNOTATIONS, schema: ToolSchemas['respond-to-event'], handler: RespondToEventHandler } @@ -1052,8 +1098,10 @@ export class ToolRegistry { server.registerTool( tool.name, { + title: tool.title, description: tool.description, - inputSchema: this.extractSchemaShape(tool.schema) + inputSchema: this.extractSchemaShape(tool.schema), + annotations: tool.annotations }, async (args: any) => { // Preprocess: Normalize datetime fields (convert object format to string format) From 3f5b05bf93d1dc314d2f77097aed333bc770abbf Mon Sep 17 00:00:00 2001 From: Nathan Spady Date: Mon, 2 Mar 2026 20:40:22 -0800 Subject: [PATCH 2/2] Fix tool annotations, manage-accounts registration, and resource error handling - Migrate manage-accounts from server.tool() to registerTool() - Remove unused ToolAnnotations import - Fix idempotentHint: true for update-event and respond-to-event - Add try/catch to calendar-accounts resource callback - Update tests for registerTool and add prompt/resource callback tests --- src/server.ts | 210 ++++++++++++++-- .../unit/schemas/tool-registration.test.ts | 4 +- .../server/GoogleCalendarMcpServer.test.ts | 235 +++++++++++++++++- src/tools/registry.ts | 17 +- 4 files changed, 434 insertions(+), 32 deletions(-) diff --git a/src/server.ts b/src/server.ts index 6d2e53b..693e43d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; + import { OAuth2Client } from "google-auth-library"; import { readFileSync } from "fs"; import { join, dirname } from "path"; @@ -17,6 +17,7 @@ import { ToolRegistry } from './tools/registry.js'; // Import account management handler import { ManageAccountsHandler, ServerContext } from './handlers/core/ManageAccountsHandler.js'; import { z } from 'zod'; +import { CalendarRegistry } from './services/CalendarRegistry.js'; // Import transport handlers import { StdioTransportHandler } from './transports/stdio.js'; @@ -59,6 +60,8 @@ export class GoogleCalendarMcpServer { // 4. Set up Modern Tool Definitions this.registerTools(); + this.registerPrompts(); + this.registerResources(); // 5. Set up Graceful Shutdown this.setupGracefulShutdown(); @@ -134,31 +137,204 @@ export class GoogleCalendarMcpServer { }; const manageAccountsHandler = new ManageAccountsHandler(); - const manageAccountsAnnotations: ToolAnnotations = { - title: "Manage Connected Google Accounts", - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false - }; - this.server.tool( + this.server.registerTool( 'manage-accounts', - "Manage Google account authentication. Actions: 'list' (show accounts), 'add' (authenticate new account), 'remove' (remove account).", { - action: z.enum(['list', 'add', 'remove']) - .describe("Action to perform: 'list' shows all accounts, 'add' authenticates a new account, 'remove' removes an account"), - account_id: z.string() - .regex(/^[a-z0-9_-]{1,64}$/, "Account nickname must be 1-64 characters: lowercase letters, numbers, dashes, underscores only") - .optional() - .describe("Account nickname (e.g., 'work', 'personal') - a friendly name to identify this Google account. Required for 'add' and 'remove'. Optional for 'list' (shows all if omitted)") + title: 'Manage Google Accounts', + description: "Manage Google account authentication. Actions: 'list' (show accounts), 'add' (authenticate new account), 'remove' (remove account).", + inputSchema: { + action: z.enum(['list', 'add', 'remove']) + .describe("Action to perform: 'list' shows all accounts, 'add' authenticates a new account, 'remove' removes an account"), + account_id: z.string() + .regex(/^[a-z0-9_-]{1,64}$/, "Account nickname must be 1-64 characters: lowercase letters, numbers, dashes, underscores only") + .optional() + .describe("Account nickname (e.g., 'work', 'personal') - a friendly name to identify this Google account. Required for 'add' and 'remove'. Optional for 'list' (shows all if omitted)") + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + } }, - manageAccountsAnnotations, async (args) => { return manageAccountsHandler.runTool(args, serverContext); } ); } + private registerPrompts(): void { + this.server.registerPrompt( + 'daily-agenda-brief', + { + title: 'Daily Agenda Brief', + description: 'Generate a concise daily agenda brief with priorities, risks, and focus blocks.', + argsSchema: { + date: z.string().optional().describe("Date in YYYY-MM-DD format. Defaults to today's date in the selected timezone."), + account: z.union([z.string(), z.array(z.string())]).optional().describe("Account nickname or list of account nicknames to include."), + timeZone: z.string().optional().describe("IANA timezone (for example: America/Los_Angeles).") + } + }, + async ({ date, account, timeZone }) => { + const accountHint = Array.isArray(account) ? account.join(', ') : account; + const dateHint = date ?? 'today'; + const timeZoneHint = timeZone ?? 'calendar default timezone'; + const toolArgs: Record = { + calendarId: 'primary' + }; + if (account) { + toolArgs.account = account; + } + if (timeZone) { + toolArgs.timeZone = timeZone; + } + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: + `Create my daily agenda brief for ${dateHint} in ${timeZoneHint}. ` + + `Account scope: ${accountHint ?? 'all connected accounts'}.\n\n` + + `Use these tools in order:\n` + + `1) get-current-time to ground date/time context.\n` + + `2) list-events with args ${JSON.stringify(toolArgs)} and a time window for the requested day.\n` + + `3) get-freebusy if you need to confirm open focus blocks.\n\n` + + `Return sections:\n` + + `- Priorities (top 3)\n` + + `- Meeting Risks (overlaps, back-to-back runs, insufficient prep gaps)\n` + + `- Suggested Focus Blocks (specific time ranges)\n` + + `- Prep Checklist (owner + due-before-event)\n\n` + + `Keep it concise, actionable, and timezone-explicit.` + } + } + ] + }; + } + ); + + this.server.registerPrompt( + 'find-and-book-meeting', + { + title: 'Find and Book Meeting', + description: 'Find candidate meeting times and create an event only after explicit confirmation.', + argsSchema: { + title: z.string().describe('Meeting title.'), + attendeeEmails: z.array(z.string()).optional().describe('List of attendee emails.'), + durationMinutes: z.number().int().min(15).max(240).describe('Meeting duration in minutes.'), + windowStart: z.string().describe('Window start in ISO 8601 format.'), + windowEnd: z.string().describe('Window end in ISO 8601 format.'), + account: z.union([z.string(), z.array(z.string())]).optional().describe('Account nickname or list of account nicknames to include.'), + targetCalendarId: z.string().optional().describe("Calendar ID to book on. Defaults to 'primary'."), + timeZone: z.string().optional().describe('IANA timezone used for candidate slots and final booking.') + } + }, + async ({ title, attendeeEmails, durationMinutes, windowStart, windowEnd, account, targetCalendarId, timeZone }) => { + const freebusyArgs: Record = { + timeMin: windowStart, + timeMax: windowEnd, + calendars: [{ id: targetCalendarId ?? 'primary' }] + }; + if (account) { + freebusyArgs.account = account; + } + if (timeZone) { + freebusyArgs.timeZone = timeZone; + } + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: + `Find and book a meeting.\n\n` + + `Constraints:\n` + + `- Title: ${title}\n` + + `- Duration: ${durationMinutes} minutes\n` + + `- Window: ${windowStart} to ${windowEnd}\n` + + `- Account scope: ${Array.isArray(account) ? account.join(', ') : account ?? 'all connected accounts'}\n` + + `- Target calendar: ${targetCalendarId ?? 'primary'}\n` + + `- Timezone: ${timeZone ?? 'calendar default timezone'}\n` + + `- Attendees: ${(attendeeEmails && attendeeEmails.length > 0) ? attendeeEmails.join(', ') : 'none specified'}\n\n` + + `Workflow:\n` + + `1) Call get-current-time first.\n` + + `2) Call get-freebusy with args ${JSON.stringify(freebusyArgs)}.\n` + + `3) Propose exactly 3 ranked candidate slots with tradeoffs.\n` + + `4) Ask for explicit confirmation of one slot.\n` + + `5) Only after confirmation, call create-event with selected slot and provided attendees.\n\n` + + `Do not create an event before confirmation.` + } + } + ] + }; + } + ); + } + + private registerResources(): void { + this.server.registerResource( + 'calendar-accounts', + 'calendar://accounts', + { + title: 'Connected Accounts and Calendars', + description: 'Lists authenticated account nicknames and a deduplicated summary of accessible calendars.', + mimeType: 'application/json' + }, + async () => { + try { + await this.ensureAuthenticated(); + + const accountIds = Array.from(this.accounts.keys()).sort(); + const registry = CalendarRegistry.getInstance(); + const unifiedCalendars = await registry.getUnifiedCalendars(this.accounts); + + const payload = { + generatedAt: new Date().toISOString(), + accountCount: accountIds.length, + accountIds, + calendarCount: unifiedCalendars.length, + calendars: unifiedCalendars.map((calendar) => ({ + calendarId: calendar.calendarId, + displayName: calendar.displayName, + preferredAccount: calendar.preferredAccount, + access: calendar.accounts.map((accountAccess) => ({ + accountId: accountAccess.accountId, + accessRole: accountAccess.accessRole, + primary: accountAccess.primary + })) + })), + notes: [ + "Calendars are deduplicated across accounts by calendar ID.", + "preferredAccount is the account with the highest permissions for that calendar." + ] + }; + + return { + contents: [ + { + uri: 'calendar://accounts', + mimeType: 'application/json', + text: JSON.stringify(payload, null, 2) + } + ] + }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to load calendar accounts: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + ); + } + private async ensureAuthenticated(): Promise { const availableAccounts = await this.tokenManager.loadAllAccounts(); if (availableAccounts.size > 0) { diff --git a/src/tests/unit/schemas/tool-registration.test.ts b/src/tests/unit/schemas/tool-registration.test.ts index 082f7ae..56f7ce0 100644 --- a/src/tests/unit/schemas/tool-registration.test.ts +++ b/src/tests/unit/schemas/tool-registration.test.ts @@ -104,7 +104,7 @@ describe('Tool Registration', () => { 'update-event': { readOnlyHint: false, destructiveHint: true, - idempotentHint: false, + idempotentHint: true, openWorldHint: false }, 'delete-event': { @@ -116,7 +116,7 @@ describe('Tool Registration', () => { 'respond-to-event': { readOnlyHint: false, destructiveHint: false, - idempotentHint: false, + idempotentHint: true, openWorldHint: false } }; diff --git a/src/tests/unit/server/GoogleCalendarMcpServer.test.ts b/src/tests/unit/server/GoogleCalendarMcpServer.test.ts index 1d71229..f03adf0 100644 --- a/src/tests/unit/server/GoogleCalendarMcpServer.test.ts +++ b/src/tests/unit/server/GoogleCalendarMcpServer.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; const state = vi.hoisted(() => ({ mcpServerConfig: undefined as any, @@ -15,15 +16,22 @@ const state = vi.hoisted(() => ({ authServerStop: vi.fn(async () => undefined), toolRegistryRegisterAll: vi.fn(), manageAccountsRunTool: vi.fn(async () => ({ content: [{ type: 'text', text: 'ok' }] })), + registerTool: vi.fn(), + registerPrompt: vi.fn(), + registerResource: vi.fn(), stdioConnect: vi.fn(async () => undefined), httpConnect: vi.fn(async () => undefined), processOn: vi.fn(process.on.bind(process)), + calendarRegistryGetUnifiedCalendars: vi.fn(async () => [] as Array>), })); vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ McpServer: class MockMcpServer { connect = vi.fn(async () => undefined); tool = vi.fn(); + registerTool = state.registerTool; + registerPrompt = state.registerPrompt; + registerResource = state.registerResource; close = vi.fn(); constructor(config: any) { state.mcpServerConfig = config; @@ -71,6 +79,14 @@ vi.mock('../../../handlers/core/ManageAccountsHandler.js', () => ({ } })); +vi.mock('../../../services/CalendarRegistry.js', () => ({ + CalendarRegistry: { + getInstance: () => ({ + getUnifiedCalendars: state.calendarRegistryGetUnifiedCalendars + }) + } +})); + vi.mock('../../../transports/stdio.js', () => ({ StdioTransportHandler: class MockStdioTransportHandler { constructor(_server: any) {} @@ -101,6 +117,7 @@ describe('GoogleCalendarMcpServer', () => { state.authServerStop.mockResolvedValue(undefined); state.stdioConnect.mockResolvedValue(undefined); state.httpConnect.mockResolvedValue(undefined); + state.calendarRegistryGetUnifiedCalendars.mockResolvedValue([]); process.env.NODE_ENV = 'test'; vi.spyOn(process, 'on').mockImplementation(state.processOn as any); }); @@ -116,18 +133,46 @@ describe('GoogleCalendarMcpServer', () => { expect(state.initializeOAuth2Client).toHaveBeenCalledTimes(1); expect(state.tokenManagerLoadAllAccounts).toHaveBeenCalledTimes(1); expect(state.toolRegistryRegisterAll).toHaveBeenCalledTimes(1); - expect(state.mcpServerInstance.tool).toHaveBeenCalledTimes(1); - expect(state.mcpServerInstance.tool).toHaveBeenCalledWith( + expect(state.mcpServerInstance.tool).not.toHaveBeenCalled(); + expect(state.registerTool).toHaveBeenCalledTimes(1); + expect(state.registerPrompt).toHaveBeenCalledTimes(2); + expect(state.registerResource).toHaveBeenCalledTimes(1); + expect(state.registerTool).toHaveBeenCalledWith( 'manage-accounts', - expect.any(String), - expect.any(Object), - { + expect.objectContaining({ title: 'Manage Google Accounts', - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false - }, + description: expect.any(String), + inputSchema: expect.any(Object), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false + } + }), + expect.any(Function) + ); + expect(state.registerPrompt).toHaveBeenCalledWith( + 'daily-agenda-brief', + expect.objectContaining({ + title: 'Daily Agenda Brief' + }), + expect.any(Function) + ); + expect(state.registerPrompt).toHaveBeenCalledWith( + 'find-and-book-meeting', + expect.objectContaining({ + title: 'Find and Book Meeting' + }), + expect.any(Function) + ); + expect(state.registerResource).toHaveBeenCalledWith( + 'calendar-accounts', + 'calendar://accounts', + expect.objectContaining({ + title: 'Connected Accounts and Calendars', + mimeType: 'application/json' + }), expect.any(Function) ); expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); @@ -200,6 +245,174 @@ describe('GoogleCalendarMcpServer', () => { expect(state.tokenManagerValidateTokens).not.toHaveBeenCalled(); }); + describe('prompt callbacks', () => { + async function initAndGetPromptCallback(promptName: string) { + const server = new GoogleCalendarMcpServer({ + transport: { type: 'stdio' }, + debug: false + } as any); + await server.initialize(); + const call = state.registerPrompt.mock.calls.find( + (c: any[]) => c[0] === promptName + )!; + return call[2]; + } + + it('daily-agenda-brief returns correct message with defaults', async () => { + const callback = await initAndGetPromptCallback('daily-agenda-brief'); + const result = await callback({}); + const text = result.messages[0].content.text; + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(text).toContain('today'); + expect(text).toContain('all connected accounts'); + }); + + it('daily-agenda-brief interpolates date, account string, and timezone', async () => { + const callback = await initAndGetPromptCallback('daily-agenda-brief'); + const result = await callback({ + date: '2026-03-15', + account: 'work', + timeZone: 'America/New_York' + }); + const text = result.messages[0].content.text; + + expect(text).toContain('2026-03-15'); + expect(text).toContain('America/New_York'); + expect(text).toContain('work'); + }); + + it('daily-agenda-brief joins array accounts', async () => { + const callback = await initAndGetPromptCallback('daily-agenda-brief'); + const result = await callback({ + account: ['work', 'personal'] + }); + const text = result.messages[0].content.text; + + expect(text).toContain('work, personal'); + }); + + it('find-and-book-meeting returns correct message with full args', async () => { + const callback = await initAndGetPromptCallback('find-and-book-meeting'); + const result = await callback({ + title: 'Sprint Review', + durationMinutes: 60, + windowStart: '2026-03-10T09:00:00', + windowEnd: '2026-03-10T17:00:00', + attendeeEmails: ['alice@example.com', 'bob@example.com'], + account: 'work', + targetCalendarId: 'team-cal', + timeZone: 'Europe/London' + }); + const text = result.messages[0].content.text; + + expect(text).toContain('Sprint Review'); + expect(text).toContain('60 minutes'); + expect(text).toContain('alice@example.com, bob@example.com'); + expect(text).toContain('team-cal'); + expect(text).toContain('Europe/London'); + expect(text).toContain('Do not create an event before confirmation'); + }); + + it('find-and-book-meeting uses defaults for optional args', async () => { + const callback = await initAndGetPromptCallback('find-and-book-meeting'); + const result = await callback({ + title: 'Sync', + durationMinutes: 30, + windowStart: '2026-03-10T09:00:00', + windowEnd: '2026-03-10T17:00:00' + }); + const text = result.messages[0].content.text; + + expect(text).toContain('primary'); + expect(text).toContain('none specified'); + }); + }); + + describe('resource callbacks', () => { + async function initAndGetResourceCallback() { + // Ensure ensureAuthenticated succeeds by providing accounts + state.tokenManagerLoadAllAccounts.mockResolvedValue( + new Map([['work', {} as any]]) + ); + + const server = new GoogleCalendarMcpServer({ + transport: { type: 'stdio' }, + debug: false + } as any); + await server.initialize(); + + const call = state.registerResource.mock.calls.find( + (c: any[]) => c[0] === 'calendar-accounts' + )!; + return call[3]; + } + + it('returns structured JSON payload on success', async () => { + state.calendarRegistryGetUnifiedCalendars.mockResolvedValue([ + { + calendarId: 'cal-1', + displayName: 'Work Calendar', + preferredAccount: 'work', + accounts: [ + { accountId: 'work', accessRole: 'owner', primary: true } + ] + } + ]); + + const callback = await initAndGetResourceCallback(); + const result = await callback(); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].uri).toBe('calendar://accounts'); + expect(result.contents[0].mimeType).toBe('application/json'); + + const payload = JSON.parse(result.contents[0].text); + expect(payload.accountCount).toBe(1); + expect(payload.accountIds).toEqual(['work']); + expect(payload.calendarCount).toBe(1); + expect(payload.calendars[0].calendarId).toBe('cal-1'); + expect(payload.calendars[0].preferredAccount).toBe('work'); + }); + + it('wraps non-McpError exceptions in McpError', async () => { + state.calendarRegistryGetUnifiedCalendars.mockRejectedValue( + new Error('network failure') + ); + + const callback = await initAndGetResourceCallback(); + + await expect(callback()).rejects.toThrow(McpError); + await expect(callback()).rejects.toThrow('Failed to load calendar accounts: network failure'); + }); + + it('re-throws McpError instances directly', async () => { + state.tokenManagerLoadAllAccounts.mockResolvedValue(new Map()); + state.tokenManagerValidateTokens.mockResolvedValue(false); + + const server = new GoogleCalendarMcpServer({ + transport: { type: 'stdio' }, + debug: false + } as any); + await server.initialize(); + + const call = state.registerResource.mock.calls.find( + (c: any[]) => c[0] === 'calendar-accounts' + )!; + const callback = call[3]; + + // ensureAuthenticated will throw McpError because no accounts and no valid tokens + await expect(callback()).rejects.toThrow(McpError); + try { + await callback(); + } catch (err) { + expect(err).toBeInstanceOf(McpError); + expect((err as McpError).code).toBe(ErrorCode.InvalidRequest); + } + }); + }); + afterEach(() => { process.env.NODE_ENV = originalEnv; }); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 592f5b1..274fc5d 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -795,6 +795,19 @@ const WRITE_DESTRUCTIVE_ANNOTATIONS: ToolAnnotations = { openWorldHint: false }; +const WRITE_DESTRUCTIVE_IDEMPOTENT_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false +}; + +const WRITE_NON_DESTRUCTIVE_IDEMPOTENT_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false +}; export class ToolRegistry { private static extractSchemaShape(schema: z.ZodType): any { @@ -931,7 +944,7 @@ export class ToolRegistry { name: "update-event", title: "Update Calendar Event", description: "Update an existing calendar event with recurring event modification scope support.", - annotations: WRITE_DESTRUCTIVE_ANNOTATIONS, + annotations: WRITE_DESTRUCTIVE_IDEMPOTENT_ANNOTATIONS, schema: ToolSchemas['update-event'], handler: UpdateEventHandler }, @@ -963,7 +976,7 @@ export class ToolRegistry { name: "respond-to-event", title: "Respond to Event Invitation", description: "Respond to a calendar event invitation with Accept, Decline, Maybe (Tentative), or No Response.", - annotations: WRITE_NON_DESTRUCTIVE_ANNOTATIONS, + annotations: WRITE_NON_DESTRUCTIVE_IDEMPOTENT_ANNOTATIONS, schema: ToolSchemas['respond-to-event'], handler: RespondToEventHandler }