sh-mcp/packages/calendar/src/tools.ts

344 lines
11 KiB
TypeScript
Raw Normal View History

Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2) * Phase 0b slice: monorepo scaffold + shared core + integration packages The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers. - Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate), eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu). - @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry, redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2. - 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base, gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind injected client interfaces; finance handlers call redact(). Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally deferred until the 0a spike resolves it (G16/§0.4). * Complete Cognito auth provider + Phase 1 build brief Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5). * ci: disable cdk synth for Phase 0b (no CDK app yet) The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails with '--app is required'. Disable it here; Phase 1 re-enables it with the server CDK stubs.
2026-06-26 12:42:17 -04:00
/**
* 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<GetCalendarEventsInput, GetCalendarEventsOutput>({
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<GetCalendarEventsOutput> => {
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<CheckAvailabilityInput, CheckAvailabilityOutput>({
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<CheckAvailabilityOutput> => {
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<CreateCalendarEventInput, CreateCalendarEventOutput>({
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<CreateCalendarEventOutput> => {
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;
}