mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-05 03:42:06 +00:00
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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;
|
|||
|
|
}
|