278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
/**
|
|
* 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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(docId);
|
|
if (!doc?.events?.[event_id]) {
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }], isError: true };
|
|
}
|
|
|
|
syncServer.changeDoc<CalendarDoc>(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<CalendarDoc>(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<string, { status: string; lastSyncedAt: number }> = {};
|
|
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) }] };
|
|
},
|
|
);
|
|
}
|