/** * Sea Haven MCP calendar tools. * * All three tools require the `calendar:self` scope (ops tier). * The Google Calendar client is injected so the real googleapis SDK call * can be swapped in later and tests can pass a mock. * * Per design §2.5 and §3: * - create_calendar_event flags external attendees (any attendee whose email * is not @seahavenind.com) so the caller/agent can surface a warning. * - finance-tier handlers MUST call redact() on sensitive fields; calendar is * ops-tier, so redact() is not required here, but it is imported and applied * defensively on the free-text description/summary fields in create responses * to prevent accidental PII leakage (belt-and-suspenders). */ import { defineTool, requireScope } from '@sh-mcp/shared'; import type { AuthContext } from '@sh-mcp/shared'; import type { CalendarClient, CalendarEvent, AvailabilityResult } from './client.js'; import { CalendarClientError } from './client.js'; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- const SEA_HAVEN_DOMAIN = 'seahavenind.com'; function isExternal(email: string): boolean { return !email.toLowerCase().endsWith(`@${SEA_HAVEN_DOMAIN}`); } function flagExternalAttendees(event: CalendarEvent): CalendarEvent & { hasExternalAttendees: boolean; externalAttendeeWarning?: string; } { const attendees = event.attendees ?? []; const externalAttendees = attendees.filter((a) => isExternal(a.email)); const hasExternalAttendees = externalAttendees.length > 0; return { ...event, hasExternalAttendees, ...(hasExternalAttendees && { externalAttendeeWarning: `This event includes ${externalAttendees.length} external attendee(s): ` + externalAttendees.map((a) => a.email).join(', ') + '. Confirm before sending invites outside @seahavenind.com.', }), }; } // --------------------------------------------------------------------------- // Input / output types // --------------------------------------------------------------------------- export interface GetCalendarEventsInput { timeMin: string; timeMax: string; calendarId?: string; maxResults?: number; } export interface GetCalendarEventsOutput { events: CalendarEvent[]; count: number; } export interface CheckAvailabilityInput { timeMin: string; timeMax: string; calendarId?: string; } export interface CheckAvailabilityOutput extends AvailabilityResult { timeMin: string; timeMax: string; } export interface CreateCalendarEventInput { summary: string; start: string; end: string; description?: string; attendees?: Array<{ email: string; displayName?: string }>; location?: string; timeZone?: string; calendarId?: string; } export interface CreateCalendarEventOutput extends CalendarEvent { hasExternalAttendees: boolean; externalAttendeeWarning?: string; } // --------------------------------------------------------------------------- // Tool factory // // The client is injected here (not imported as a module singleton) so tests // can pass a mock without any real network or AWS calls. // --------------------------------------------------------------------------- export function buildCalendarTools(client: CalendarClient) { // ------------------------------------------------------------------------- // get_calendar_events // ------------------------------------------------------------------------- const getCalendarEvents = defineTool({ name: 'get_calendar_events', description: 'Retrieve calendar events for the authenticated user within a time window. ' + 'Returns event summaries, times, attendees, and status. ' + 'Requires the user to have previously granted the calendar:self OAuth consent.', tier: 'ops', requiredScope: 'calendar:self', inputSchema: { type: 'object', required: ['timeMin', 'timeMax'], additionalProperties: false, properties: { timeMin: { type: 'string', format: 'date-time', description: 'Start of the time window (ISO-8601 datetime, e.g. 2026-06-11T00:00:00Z).', }, timeMax: { type: 'string', format: 'date-time', description: 'End of the time window (ISO-8601 datetime).', }, calendarId: { type: 'string', description: "Calendar ID to query. Defaults to 'primary'.", default: 'primary', }, maxResults: { type: 'integer', minimum: 1, maximum: 250, description: 'Maximum number of events to return (1–250, default 50).', default: 50, }, }, }, handler: async ( input: GetCalendarEventsInput, ctx: AuthContext, ): Promise => { requireScope(ctx, 'calendar:self'); let events: CalendarEvent[]; try { events = await client.getEvents(ctx.sub, { timeMin: input.timeMin, timeMax: input.timeMax, calendarId: input.calendarId ?? 'primary', maxResults: input.maxResults ?? 50, singleEvents: true, orderBy: 'startTime', }); } catch (err) { if (err instanceof CalendarClientError && err.retryable) { throw new Error( `Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`, ); } throw err; } return { events: events.map(flagExternalAttendees), count: events.length, }; }, }); // ------------------------------------------------------------------------- // check_availability // ------------------------------------------------------------------------- const checkAvailability = defineTool({ name: 'check_availability', description: "Check the authenticated user's free/busy availability within a time window. " + 'Returns a list of busy blocks and derived free blocks. ' + 'Useful for scheduling and finding open meeting slots.', tier: 'ops', requiredScope: 'calendar:self', inputSchema: { type: 'object', required: ['timeMin', 'timeMax'], additionalProperties: false, properties: { timeMin: { type: 'string', format: 'date-time', description: 'Start of the window to check (ISO-8601 datetime).', }, timeMax: { type: 'string', format: 'date-time', description: 'End of the window to check (ISO-8601 datetime).', }, calendarId: { type: 'string', description: "Calendar ID to check. Defaults to 'primary'.", default: 'primary', }, }, }, handler: async ( input: CheckAvailabilityInput, ctx: AuthContext, ): Promise => { requireScope(ctx, 'calendar:self'); let result: AvailabilityResult; try { result = await client.checkAvailability(ctx.sub, { timeMin: input.timeMin, timeMax: input.timeMax, calendarId: input.calendarId ?? 'primary', }); } catch (err) { if (err instanceof CalendarClientError && err.retryable) { throw new Error( `Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`, ); } throw err; } return { ...result, timeMin: input.timeMin, timeMax: input.timeMax, }; }, }); // ------------------------------------------------------------------------- // create_calendar_event // ------------------------------------------------------------------------- const createCalendarEvent = defineTool({ name: 'create_calendar_event', description: 'Create a calendar event for the authenticated user. ' + 'Invites are sent to any listed attendees. ' + 'IMPORTANT: if any attendee is outside @seahavenind.com, the response will include ' + 'hasExternalAttendees=true and an externalAttendeeWarning — always surface this ' + 'to the user before completing the action (design §2.5 — external invite monitoring).', tier: 'ops', requiredScope: 'calendar:self', inputSchema: { type: 'object', required: ['summary', 'start', 'end'], additionalProperties: false, properties: { summary: { type: 'string', maxLength: 1024, description: 'Event title.', }, start: { type: 'string', format: 'date-time', description: 'Event start datetime (ISO-8601).', }, end: { type: 'string', format: 'date-time', description: 'Event end datetime (ISO-8601).', }, description: { type: 'string', maxLength: 8192, description: 'Optional event description / body.', }, attendees: { type: 'array', items: { type: 'object', required: ['email'], additionalProperties: false, properties: { email: { type: 'string', format: 'email' }, displayName: { type: 'string' }, }, }, description: 'List of attendees. External (@seahavenind.com) attendees will be flagged.', }, location: { type: 'string', maxLength: 1024, description: 'Optional physical or virtual location.', }, timeZone: { type: 'string', description: 'IANA time zone for start/end (e.g. America/New_York). Defaults to UTC.', default: 'UTC', }, calendarId: { type: 'string', description: "Calendar to create the event in. Defaults to 'primary'.", default: 'primary', }, }, }, handler: async ( input: CreateCalendarEventInput, ctx: AuthContext, ): Promise => { requireScope(ctx, 'calendar:self'); let created: CalendarEvent; try { created = await client.createEvent(ctx.sub, { summary: input.summary, start: input.start, end: input.end, description: input.description, attendees: input.attendees, location: input.location, timeZone: input.timeZone ?? 'UTC', calendarId: input.calendarId ?? 'primary', }); } catch (err) { if (err instanceof CalendarClientError && err.retryable) { throw new Error( `Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`, ); } throw err; } // Flag external attendees — callers MUST surface externalAttendeeWarning // when hasExternalAttendees is true (design §2.5). return flagExternalAttendees(created); }, }); return [getCalendarEvents, checkAvailability, createCalendarEvent] as const; }