rspace-online/server/mcp-tools/rcal.ts

220 lines
8.0 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"),
},
async ({ space, token, start, end, search, limit, upcoming_days, tags }) => {
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)),
);
}
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 }) }] };
},
);
}