Files
caldav-mcp/src/tools.ts

245 lines
8.8 KiB
TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { CaldavClient } from "./caldav/client.js";
import { Config } from "./config.js";
import { mapErrorToMcp, UnauthenticatedError } from "./errors.js";
import { buildIcsFromStructured } from "./ics.js";
import { Logger } from "./logger.js";
import { getCorrelationId, getHeader, normalizeHref } from "./util.js";
interface ToolDeps {
client: CaldavClient;
config: Config;
logger: Logger;
}
export function registerTools(server: McpServer, deps: ToolDeps): void {
const { client, config, logger } = deps;
const withContext = async <T>(
extra: { requestInfo?: { headers: Record<string, string | string[] | undefined> } },
callback: (ctx: { correlationId: string; auth: { authorization?: string; cookie?: string } }) => Promise<T>,
): Promise<T> => {
try {
const headers = extra.requestInfo?.headers ?? {};
const authorization = getHeader(headers, "authorization");
if (!authorization) {
throw new UnauthenticatedError("Missing Authorization header");
}
const cookie = getHeader(headers, "cookie");
const correlationId = getCorrelationId(headers);
return await callback({ correlationId, auth: { authorization, cookie } });
} catch (error) {
logger.error("Tool execution failed", { error: error instanceof Error ? error.message : String(error) });
throw mapErrorToMcp(error);
}
};
server.registerTool(
"caldav_discover_principal",
{
description: "Resolve current-user-principal and calendar-home-set for authenticated caller",
inputSchema: z.object({}),
},
async (_args, extra) => {
const result = await withContext(extra, async (context) => client.discoverPrincipal(context));
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
structuredContent: { principal: result },
};
},
);
server.registerTool(
"caldav_list_calendars",
{
description: "List calendars visible under principal",
inputSchema: z.object({}),
},
async (_args, extra) => {
const result = await withContext(extra, async (context) => {
const principal = await client.discoverPrincipal(context);
return client.listCalendars(context, principal);
});
return {
content: [{ type: "text", text: JSON.stringify({ calendars: result }, null, 2) }],
structuredContent: { calendars: result },
};
},
);
server.registerTool(
"caldav_create_calendar",
{
description: "Create a new calendar if server supports MKCALENDAR",
inputSchema: z.object({
displayName: z.string().min(1),
slug: z.string().min(1),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const principal = await client.discoverPrincipal(context);
const capabilities = await client.getCapabilities(context, principal.calendarHomeHref);
if (!capabilities.canMkcalendar) {
throw new Error("Upstream does not support MKCALENDAR");
}
return client.createCalendar(context, principal.calendarHomeHref, args.displayName, args.slug);
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_update_calendar",
{
description: "Update calendar displayname if PROPPATCH is supported",
inputSchema: z.object({
calendarHref: z.string().url(),
displayName: z.string().min(1),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const principal = await client.discoverPrincipal(context);
const capabilities = await client.getCapabilities(context, principal.calendarHomeHref);
if (!capabilities.canProppatch) {
throw new Error("Upstream does not support PROPPATCH");
}
await client.updateCalendar(context, normalizeHref(args.calendarHref, config.CALDAV_BASE_URL), args.displayName);
return { href: normalizeHref(args.calendarHref, config.CALDAV_BASE_URL) };
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_delete_calendar",
{
description: "Delete a calendar",
inputSchema: z.object({
calendarHref: z.string().url(),
etag: z.string().optional(),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const href = normalizeHref(args.calendarHref, config.CALDAV_BASE_URL);
await client.deleteCalendar(context, href, args.etag);
return { href, deleted: true };
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_list_events",
{
description: "List VEVENT resources in a time range",
inputSchema: z.object({
calendarHref: z.string().url(),
rangeStart: z.string().min(1),
rangeEnd: z.string().min(1),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const href = normalizeHref(args.calendarHref, config.CALDAV_BASE_URL);
const events = await client.listEvents(context, href, args.rangeStart, args.rangeEnd);
return { calendarHref: href, events };
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
},
);
server.registerTool(
"caldav_get_event",
{
description: "Get event by href",
inputSchema: z.object({
eventHref: z.string().url(),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const href = normalizeHref(args.eventHref, config.CALDAV_BASE_URL);
return client.getEvent(context, href);
});
return { content: [{ type: "text", text: result.ics }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_create_event",
{
description: "Create a VEVENT resource from ICS or structured input",
inputSchema: z
.object({
calendarHref: z.string().url(),
eventHref: z.string().url().optional(),
ics: z.string().optional(),
event: z
.object({
uid: z.string().optional(),
summary: z.string().optional(),
description: z.string().optional(),
location: z.string().optional(),
dtstart: z.string(),
dtend: z.string().optional(),
timezone: z.string().optional(),
})
.optional(),
})
.refine((value) => Boolean(value.ics) || Boolean(value.event), "Provide either ics or event"),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const calendarHref = normalizeHref(args.calendarHref, config.CALDAV_BASE_URL);
const eventHref = args.eventHref ? normalizeHref(args.eventHref, config.CALDAV_BASE_URL) : undefined;
const ics = args.ics ?? buildIcsFromStructured(args.event!);
return client.createEvent(context, calendarHref, ics, eventHref);
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_update_event",
{
description: "Update an existing VEVENT resource",
inputSchema: z.object({
eventHref: z.string().url(),
ics: z.string(),
etag: z.string().optional(),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const href = normalizeHref(args.eventHref, config.CALDAV_BASE_URL);
const update = await client.updateEvent(context, href, args.ics, args.etag);
return { href, ...update };
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
server.registerTool(
"caldav_delete_event",
{
description: "Delete VEVENT resource",
inputSchema: z.object({
eventHref: z.string().url(),
etag: z.string().optional(),
}),
},
async (args, extra) => {
const result = await withContext(extra, async (context) => {
const href = normalizeHref(args.eventHref, config.CALDAV_BASE_URL);
await client.deleteEvent(context, href, args.etag);
return { href, deleted: true };
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: { result } };
},
);
}