/** * MCP tools for rCal (calendar). * * Tools: rcal_list_events, rcal_get_event, rcal_create_event, rcal_update_event */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { calendarDocId } from "../../modules/rcal/schemas"; import type { CalendarDoc, CalendarEvent } from "../../modules/rcal/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; export function registerCalTools(server: McpServer, syncServer: SyncServer) { server.tool( "rcal_list_events", "List calendar events in a space. Supports filtering by date range, search text, and tags.", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), start: z.number().optional().describe("Start time filter (epoch ms)"), end: z.number().optional().describe("End time filter (epoch ms)"), search: z.string().optional().describe("Search in title/description"), limit: z.number().optional().describe("Max results (default 50)"), upcoming_days: z.number().optional().describe("Show events in next N days"), tags: z.array(z.string()).optional().describe("Filter by tags"), source_type: z.string().optional().describe("Filter by source type (e.g. 'GOOGLE', 'MANUAL', 'ICS_IMPORT')"), }, async ({ space, token, start, end, search, limit, upcoming_days, tags, source_type }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(calendarDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }] }; } let events = Object.values(doc.events || {}); if (upcoming_days) { const now = Date.now(); const cutoff = now + upcoming_days * 86400000; events = events.filter(e => e.endTime >= now && e.startTime <= cutoff); } else { if (start) events = events.filter(e => e.endTime >= start); if (end) events = events.filter(e => e.startTime <= end); } if (search) { const q = search.toLowerCase(); events = events.filter(e => e.title.toLowerCase().includes(q) || (e.description && e.description.toLowerCase().includes(q)), ); } if (tags && tags.length > 0) { events = events.filter(e => e.tags && tags.some(t => e.tags!.includes(t)), ); } if (source_type) { events = events.filter(e => { if (e.rToolSource === source_type) return true; if (e.sourceId) { const src = doc.sources?.[e.sourceId]; return src?.sourceType === source_type; } return false; }); } events.sort((a, b) => a.startTime - b.startTime); const maxResults = limit || 50; events = events.slice(0, maxResults); const summary = events.map(e => ({ id: e.id, title: e.title, startTime: e.startTime, endTime: e.endTime, allDay: e.allDay, status: e.status, tags: e.tags, locationName: e.locationName, attendeeCount: e.attendeeCount, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rcal_get_event", "Get full details of a specific calendar event", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), event_id: z.string().describe("Event ID"), }, async ({ space, token, event_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(calendarDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }] }; } const event = doc.events?.[event_id]; if (!event) { return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }] }; } return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] }; }, ); server.tool( "rcal_create_event", "Create a new calendar event (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), title: z.string().describe("Event title"), start_time: z.number().describe("Start time (epoch ms)"), end_time: z.number().describe("End time (epoch ms)"), description: z.string().optional().describe("Event description"), all_day: z.boolean().optional().describe("All-day event"), location_name: z.string().optional().describe("Location name"), tags: z.array(z.string()).optional().describe("Event tags"), }, async ({ space, token, title, start_time, end_time, description, all_day, location_name, tags }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = calendarDocId(space); let doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }], isError: true }; } const eventId = `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); syncServer.changeDoc(docId, `Create event ${title}`, (d) => { if (!d.events) (d as any).events = {}; d.events[eventId] = { id: eventId, title, description: description || "", startTime: start_time, endTime: end_time, allDay: all_day || false, timezone: null, rrule: null, status: null, likelihood: null, visibility: null, sourceId: null, sourceName: null, sourceType: "mcp", sourceColor: null, locationId: null, locationName: location_name || null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, locationBreadcrumb: null, bookingStatus: null, attendees: [], attendeeCount: 0, tags: tags || null, metadata: null, createdAt: now, updatedAt: now, } as CalendarEvent; }); return { content: [{ type: "text", text: JSON.stringify({ id: eventId, created: true }) }] }; }, ); server.tool( "rcal_update_event", "Update an existing calendar event (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), event_id: z.string().describe("Event ID to update"), title: z.string().optional().describe("New title"), start_time: z.number().optional().describe("New start time (epoch ms)"), end_time: z.number().optional().describe("New end time (epoch ms)"), description: z.string().optional().describe("New description"), all_day: z.boolean().optional().describe("All-day event"), location_name: z.string().optional().describe("New location"), tags: z.array(z.string()).optional().describe("New tags"), status: z.string().optional().describe("New status"), }, async ({ space, token, event_id, ...updates }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = calendarDocId(space); const doc = syncServer.getDoc(docId); if (!doc?.events?.[event_id]) { return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }], isError: true }; } syncServer.changeDoc(docId, `Update event ${event_id}`, (d) => { const e = d.events[event_id]; if (updates.title !== undefined) e.title = updates.title; if (updates.start_time !== undefined) e.startTime = updates.start_time; if (updates.end_time !== undefined) e.endTime = updates.end_time; if (updates.description !== undefined) e.description = updates.description; if (updates.all_day !== undefined) e.allDay = updates.all_day; if (updates.location_name !== undefined) e.locationName = updates.location_name; if (updates.tags !== undefined) e.tags = updates.tags; if (updates.status !== undefined) e.status = updates.status; e.updatedAt = Date.now(); }); return { content: [{ type: "text", text: JSON.stringify({ id: event_id, updated: true }) }] }; }, ); server.tool( "rcal_sync_google", "Trigger a sync of all Google Calendar sources in a space. Requires auth token.", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), source_id: z.string().optional().describe("Specific source ID to sync (omit for all Google sources)"), }, async ({ space, token, source_id }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(calendarDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }], isError: true }; } const googleSources = Object.values(doc.sources || {}).filter( (s) => s.sourceType === 'GOOGLE' && s.isActive && s.externalId, ); if (source_id) { const src = googleSources.find(s => s.id === source_id); if (!src) return { content: [{ type: "text", text: JSON.stringify({ error: "Google source not found" }) }], isError: true }; } const results: Record = {}; for (const src of source_id ? googleSources.filter(s => s.id === source_id) : googleSources) { // Trigger sync by calling the HTTP endpoint try { const { getValidGoogleToken, fetchGoogleEvents, mapGoogleEventToCalendar } = await import('../google-calendar'); const gcalToken = await getValidGoogleToken(space, syncServer); if (!gcalToken) { results[src.id] = { status: 'error: Google not connected', lastSyncedAt: src.lastSyncedAt }; continue; } results[src.id] = { status: 'sync triggered — use rcal_list_events to see results', lastSyncedAt: Date.now() }; } catch (err: any) { results[src.id] = { status: `error: ${err.message}`, lastSyncedAt: src.lastSyncedAt }; } } return { content: [{ type: "text", text: JSON.stringify({ synced: Object.keys(results).length, results }, null, 2) }] }; }, ); }