implement CalDAV MCP server v1 with streamable HTTP tools
This commit is contained in:
7
.env.template
Normal file
7
.env.template
Normal file
@@ -0,0 +1,7 @@
|
||||
CALDAV_BASE_URL=https://caldav.example.com/caldav
|
||||
MCP_HTTP_PORT=3000
|
||||
LOG_LEVEL=info
|
||||
ALLOW_COOKIE_PASSTHROUGH=false
|
||||
CALDAV_TIMEOUT_MS=15000
|
||||
CALDAV_RETRY_COUNT=1
|
||||
EVENT_HREF_STRATEGY=uid
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
60
README.md
60
README.md
@@ -1,3 +1,61 @@
|
||||
# caldav-mcp
|
||||
|
||||
A Model Context Protocol (MCP) server for interacting with CalDAV servers
|
||||
CalDAV MCP server (Streamable HTTP) with per-request auth pass-through and dynamic principal discovery.
|
||||
|
||||
## Features
|
||||
|
||||
- Streamable MCP endpoint at `POST /mcp`
|
||||
- Health endpoint at `GET /healthz`
|
||||
- Dynamic discovery via `current-user-principal` and `calendar-home-set`
|
||||
- Calendar tools: list/create/update/delete (create/update capability-gated)
|
||||
- Event tools: list/get/create/update/delete with ETag conditional controls
|
||||
- Structured HTTP/WebDAV error mapping into MCP-safe errors
|
||||
- Request correlation IDs with auth header redaction in logs
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `CALDAV_BASE_URL` (required, no default)
|
||||
- `MCP_HTTP_PORT` (default: `3000`)
|
||||
- `LOG_LEVEL` (`debug|info|warn|error`, default: `info`)
|
||||
- `ALLOW_COOKIE_PASSTHROUGH` (`true|false`, default: `false`)
|
||||
- `CALDAV_TIMEOUT_MS` (default: `15000`)
|
||||
- `CALDAV_RETRY_COUNT` (idempotent ops only, default: `1`)
|
||||
- `EVENT_HREF_STRATEGY` (default: `uid`)
|
||||
|
||||
Copy `.env.template` to `.env` and fill in your CalDAV host before starting.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build and test:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
- `caldav_discover_principal`
|
||||
- `caldav_list_calendars`
|
||||
- `caldav_create_calendar`
|
||||
- `caldav_update_calendar`
|
||||
- `caldav_delete_calendar`
|
||||
- `caldav_list_events`
|
||||
- `caldav_get_event`
|
||||
- `caldav_create_event`
|
||||
- `caldav_update_event`
|
||||
- `caldav_delete_event`
|
||||
|
||||
## Notes
|
||||
|
||||
- Upstream host override is not supported; all requests target configured `CALDAV_BASE_URL`.
|
||||
- `Authorization` is required on incoming MCP requests and forwarded upstream.
|
||||
- `Cookie` forwarding is disabled unless `ALLOW_COOKIE_PASSTHROUGH=true`.
|
||||
- Credentials are never persisted and are redacted from logs.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 3) Principal and Auth Strategy
|
||||
|
||||
- Configure host: `https://mail.simon-franken.de/SOGo/dav`
|
||||
- Configure host: `<your-caldav-base-url>`
|
||||
- Do not hardcode principal identity; resolve dynamically for each authenticated request.
|
||||
- Per request:
|
||||
1. Forward allowlisted auth headers (`Authorization`; optional `Cookie` behind config)
|
||||
|
||||
3118
package-lock.json
generated
Normal file
3118
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "caldav-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for CalDAV",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.3",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
393
src/caldav/client.ts
Normal file
393
src/caldav/client.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Config } from "../config.js";
|
||||
import { CaldavHttpError, ValidationError } from "../errors.js";
|
||||
import { Logger } from "../logger.js";
|
||||
import { normalizeHref, normalizeIdFromHref, joinUrl } from "../util.js";
|
||||
import {
|
||||
buildCalendarHomeSetPropfindBody,
|
||||
buildCalendarListPropfindBody,
|
||||
buildCalendarQueryBody,
|
||||
buildCurrentUserPrincipalPropfindBody,
|
||||
buildMkcalendarBody,
|
||||
buildProppatchDisplayNameBody,
|
||||
parseMultiStatus,
|
||||
} from "./xml.js";
|
||||
import { ensureUid, extractIcsField, validateIcsEvent } from "../ics.js";
|
||||
|
||||
export interface AuthHeaders {
|
||||
authorization?: string;
|
||||
cookie?: string;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
correlationId: string;
|
||||
auth: AuthHeaders;
|
||||
}
|
||||
|
||||
export interface PrincipalInfo {
|
||||
principalHref: string;
|
||||
calendarHomeHref: string;
|
||||
}
|
||||
|
||||
export interface CalendarInfo {
|
||||
id: string;
|
||||
href: string;
|
||||
displayName: string;
|
||||
etag?: string;
|
||||
writable: boolean;
|
||||
components: string[];
|
||||
}
|
||||
|
||||
export interface EventInfo {
|
||||
id: string;
|
||||
href: string;
|
||||
etag?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export class CaldavClient {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async discoverPrincipal(context: RequestContext): Promise<PrincipalInfo> {
|
||||
const principalDoc = await this.request({
|
||||
context,
|
||||
method: "PROPFIND",
|
||||
url: this.config.CALDAV_BASE_URL,
|
||||
headers: { Depth: "0", "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildCurrentUserPrincipalPropfindBody(),
|
||||
expectMultistatus: true,
|
||||
idempotent: true,
|
||||
});
|
||||
|
||||
const principalHref = this.extractHrefProp(principalDoc.body, "current-user-principal");
|
||||
if (!principalHref) {
|
||||
throw new ValidationError("Unable to resolve current-user-principal");
|
||||
}
|
||||
const normalizedPrincipal = normalizeHref(principalHref, this.config.CALDAV_BASE_URL);
|
||||
|
||||
const homeDoc = await this.request({
|
||||
context,
|
||||
method: "PROPFIND",
|
||||
url: normalizedPrincipal,
|
||||
headers: { Depth: "0", "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildCalendarHomeSetPropfindBody(),
|
||||
expectMultistatus: true,
|
||||
idempotent: true,
|
||||
});
|
||||
|
||||
const calendarHomeHref = this.extractHrefProp(homeDoc.body, "calendar-home-set");
|
||||
if (!calendarHomeHref) {
|
||||
throw new ValidationError("Unable to resolve calendar-home-set");
|
||||
}
|
||||
|
||||
return {
|
||||
principalHref: normalizedPrincipal,
|
||||
calendarHomeHref: normalizeHref(calendarHomeHref, this.config.CALDAV_BASE_URL),
|
||||
};
|
||||
}
|
||||
|
||||
async listCalendars(context: RequestContext, principal: PrincipalInfo): Promise<CalendarInfo[]> {
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "PROPFIND",
|
||||
url: principal.calendarHomeHref,
|
||||
headers: { Depth: "1", "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildCalendarListPropfindBody(),
|
||||
expectMultistatus: true,
|
||||
idempotent: true,
|
||||
});
|
||||
|
||||
const resources = parseMultiStatus(response.body ?? "");
|
||||
return resources
|
||||
.map((resource): CalendarInfo | undefined => {
|
||||
const href = normalizeHref(resource.href, principal.calendarHomeHref);
|
||||
if (href === principal.calendarHomeHref) {
|
||||
return undefined;
|
||||
}
|
||||
const ok = resource.statuses.find((status) => status.status >= 200 && status.status < 300);
|
||||
if (!ok) {
|
||||
return undefined;
|
||||
}
|
||||
const resourceType = String(ok.props.resourcetype ?? "");
|
||||
if (!resourceType.toLowerCase().includes("calendar")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const displayName = String(ok.props.displayname ?? normalizeIdFromHref(href));
|
||||
const etag = normalizeEtag(ok.props.getetag);
|
||||
const writable = String(ok.props["current-user-privilege-set"] ?? "").toLowerCase().includes("write");
|
||||
const componentsRaw = String(ok.props["supported-calendar-component-set"] ?? "");
|
||||
const components = ["VEVENT", "VTODO", "VJOURNAL"].filter((component) => componentsRaw.includes(component));
|
||||
|
||||
const calendar: CalendarInfo = {
|
||||
id: normalizeIdFromHref(href),
|
||||
href,
|
||||
displayName,
|
||||
writable,
|
||||
components,
|
||||
};
|
||||
if (etag) {
|
||||
calendar.etag = etag;
|
||||
}
|
||||
return calendar;
|
||||
})
|
||||
.filter((item): item is CalendarInfo => Boolean(item));
|
||||
}
|
||||
|
||||
async getCapabilities(context: RequestContext, calendarHomeHref: string): Promise<{ canMkcalendar: boolean; canProppatch: boolean }> {
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "OPTIONS",
|
||||
url: calendarHomeHref,
|
||||
idempotent: true,
|
||||
});
|
||||
const allow = response.headers.get("allow") ?? "";
|
||||
return {
|
||||
canMkcalendar: allow.toUpperCase().includes("MKCALENDAR"),
|
||||
canProppatch: allow.toUpperCase().includes("PROPPATCH"),
|
||||
};
|
||||
}
|
||||
|
||||
async createCalendar(context: RequestContext, calendarHomeHref: string, name: string, slug: string): Promise<{ href: string }> {
|
||||
const href = joinUrl(calendarHomeHref, `${slug}/`);
|
||||
await this.request({
|
||||
context,
|
||||
method: "MKCALENDAR",
|
||||
url: href,
|
||||
headers: { "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildMkcalendarBody(name),
|
||||
expectedStatus: [201, 200],
|
||||
idempotent: false,
|
||||
});
|
||||
return { href };
|
||||
}
|
||||
|
||||
async updateCalendar(context: RequestContext, calendarHref: string, displayName: string): Promise<void> {
|
||||
await this.request({
|
||||
context,
|
||||
method: "PROPPATCH",
|
||||
url: calendarHref,
|
||||
headers: { "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildProppatchDisplayNameBody(displayName),
|
||||
expectedStatus: [200, 207],
|
||||
idempotent: false,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCalendar(context: RequestContext, calendarHref: string, etag?: string): Promise<void> {
|
||||
await this.request({
|
||||
context,
|
||||
method: "DELETE",
|
||||
url: calendarHref,
|
||||
headers: buildIfMatchHeader(etag),
|
||||
expectedStatus: [200, 204],
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
async listEvents(context: RequestContext, calendarHref: string, rangeStart: string, rangeEnd: string): Promise<EventInfo[]> {
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "REPORT",
|
||||
url: calendarHref,
|
||||
headers: { Depth: "1", "Content-Type": "application/xml; charset=utf-8" },
|
||||
body: buildCalendarQueryBody(rangeStart, rangeEnd),
|
||||
expectMultistatus: true,
|
||||
idempotent: true,
|
||||
});
|
||||
|
||||
const resources = parseMultiStatus(response.body ?? "");
|
||||
return resources
|
||||
.map((resource): EventInfo | undefined => {
|
||||
const href = normalizeHref(resource.href, calendarHref);
|
||||
const ok = resource.statuses.find((status) => status.status >= 200 && status.status < 300);
|
||||
if (!ok) {
|
||||
return undefined;
|
||||
}
|
||||
const etag = normalizeEtag(ok.props.getetag);
|
||||
const event: EventInfo = {
|
||||
id: normalizeIdFromHref(href),
|
||||
href,
|
||||
};
|
||||
if (etag) {
|
||||
event.etag = etag;
|
||||
}
|
||||
return event;
|
||||
})
|
||||
.filter((item): item is EventInfo => Boolean(item));
|
||||
}
|
||||
|
||||
async getEvent(context: RequestContext, eventHref: string): Promise<{ href: string; etag?: string; ics: string; uid?: string }> {
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "GET",
|
||||
url: eventHref,
|
||||
expectedStatus: [200],
|
||||
idempotent: true,
|
||||
});
|
||||
const ics = response.body ?? "";
|
||||
return {
|
||||
href: eventHref,
|
||||
etag: normalizeEtag(response.headers.get("etag") ?? undefined),
|
||||
uid: extractIcsField(ics, "UID"),
|
||||
ics,
|
||||
};
|
||||
}
|
||||
|
||||
async createEvent(context: RequestContext, calendarHref: string, ics: string, eventHref?: string): Promise<{ href: string; etag?: string; uid?: string }> {
|
||||
const ensured = ensureUid(ics);
|
||||
validateIcsEvent(ensured.ics);
|
||||
const href = eventHref ?? this.makeEventHref(calendarHref, ensured.uid);
|
||||
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "PUT",
|
||||
url: href,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"If-None-Match": "*",
|
||||
},
|
||||
body: ensured.ics,
|
||||
expectedStatus: [201, 204],
|
||||
idempotent: false,
|
||||
});
|
||||
|
||||
return { href, etag: normalizeEtag(response.headers.get("etag") ?? undefined), uid: ensured.uid };
|
||||
}
|
||||
|
||||
async updateEvent(context: RequestContext, eventHref: string, ics: string, etag?: string): Promise<{ etag?: string; uid?: string }> {
|
||||
validateIcsEvent(ics);
|
||||
const response = await this.request({
|
||||
context,
|
||||
method: "PUT",
|
||||
url: eventHref,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
...buildIfMatchHeader(etag),
|
||||
},
|
||||
body: ics,
|
||||
expectedStatus: [200, 201, 204],
|
||||
idempotent: false,
|
||||
});
|
||||
|
||||
return { etag: normalizeEtag(response.headers.get("etag") ?? undefined), uid: extractIcsField(ics, "UID") };
|
||||
}
|
||||
|
||||
async deleteEvent(context: RequestContext, eventHref: string, etag?: string): Promise<void> {
|
||||
await this.request({
|
||||
context,
|
||||
method: "DELETE",
|
||||
url: eventHref,
|
||||
headers: buildIfMatchHeader(etag),
|
||||
expectedStatus: [200, 204],
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(input: {
|
||||
context: RequestContext;
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
expectedStatus?: number[];
|
||||
expectMultistatus?: boolean;
|
||||
idempotent: boolean;
|
||||
}): Promise<{ status: number; body?: string; headers: Headers }> {
|
||||
const attempts = input.idempotent ? this.config.CALDAV_RETRY_COUNT + 1 : 1;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "caldav-mcp/0.1.0",
|
||||
"X-Request-ID": input.context.correlationId,
|
||||
...input.headers,
|
||||
};
|
||||
|
||||
if (input.context.auth.authorization) {
|
||||
headers.Authorization = input.context.auth.authorization;
|
||||
}
|
||||
if (input.context.auth.cookie && this.config.ALLOW_COOKIE_PASSTHROUGH) {
|
||||
headers.Cookie = input.context.auth.cookie;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.config.CALDAV_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(input.url, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
const okStatuses = input.expectedStatus ?? (input.expectMultistatus ? [207] : [200, 201, 204]);
|
||||
if (!okStatuses.includes(response.status)) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new CaldavHttpError(response.status, input.method, input.url, body.slice(0, 1000));
|
||||
}
|
||||
|
||||
const body = response.status === 204 ? undefined : await response.text().catch(() => undefined);
|
||||
return {
|
||||
status: response.status,
|
||||
body,
|
||||
headers: response.headers,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
if (attempt >= attempts) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.warn("Retrying idempotent CalDAV request", {
|
||||
correlationId: input.context.correlationId,
|
||||
method: input.method,
|
||||
url: input.url,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unreachable retry state");
|
||||
}
|
||||
|
||||
private extractHrefProp(xml: string | undefined, propName: string): string | undefined {
|
||||
if (!xml) {
|
||||
return undefined;
|
||||
}
|
||||
const resources = parseMultiStatus(xml);
|
||||
for (const resource of resources) {
|
||||
for (const status of resource.statuses) {
|
||||
const value = status.props[propName];
|
||||
if (typeof value === "object" && value && "href" in (value as Record<string, unknown>)) {
|
||||
const href = (value as Record<string, unknown>).href;
|
||||
return href ? String(href) : undefined;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private makeEventHref(calendarHref: string, uid: string): string {
|
||||
const safe = uid.toLowerCase().replace(/[^a-z0-9-_.]/g, "-");
|
||||
return joinUrl(calendarHref, `${safe}.ics`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildIfMatchHeader(etag?: string): Record<string, string> {
|
||||
if (!etag) {
|
||||
return {};
|
||||
}
|
||||
return { "If-Match": etag };
|
||||
}
|
||||
|
||||
function normalizeEtag(input: unknown): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
return String(input).trim();
|
||||
}
|
||||
159
src/caldav/xml.ts
Normal file
159
src/caldav/xml.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { XMLBuilder, XMLParser } from "fast-xml-parser";
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
removeNSPrefix: true,
|
||||
trimValues: true,
|
||||
parseTagValue: false,
|
||||
});
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
format: true,
|
||||
suppressEmptyNode: true,
|
||||
});
|
||||
|
||||
export type DavPropMap = Record<string, unknown>;
|
||||
|
||||
export interface DavResponse {
|
||||
href: string;
|
||||
statuses: Array<{ status: number; props: DavPropMap }>;
|
||||
}
|
||||
|
||||
export function parseMultiStatus(xml: string): DavResponse[] {
|
||||
const parsed = parser.parse(xml) as Record<string, unknown>;
|
||||
const multiStatus = (parsed.multistatus ?? parsed["d:multistatus"]) as Record<string, unknown> | undefined;
|
||||
if (!multiStatus) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const responses = ensureArray(multiStatus.response) as Array<Record<string, unknown>>;
|
||||
return responses.map((response) => {
|
||||
const href = String(response.href ?? "");
|
||||
const propstats = ensureArray(response.propstat) as Array<Record<string, unknown>>;
|
||||
const statuses = propstats.map((propstat) => {
|
||||
const statusCode = parseStatusCode(String(propstat.status ?? "HTTP/1.1 500"));
|
||||
const prop = (propstat.prop ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
status: statusCode,
|
||||
props: flattenProps(prop),
|
||||
};
|
||||
});
|
||||
return { href, statuses };
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCurrentUserPrincipalPropfindBody(): string {
|
||||
return builder.build({
|
||||
"d:propfind": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"d:prop": {
|
||||
"d:current-user-principal": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCalendarHomeSetPropfindBody(): string {
|
||||
return builder.build({
|
||||
"d:propfind": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"@_xmlns:c": "urn:ietf:params:xml:ns:caldav",
|
||||
"d:prop": {
|
||||
"c:calendar-home-set": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCalendarListPropfindBody(): string {
|
||||
return builder.build({
|
||||
"d:propfind": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"@_xmlns:c": "urn:ietf:params:xml:ns:caldav",
|
||||
"d:prop": {
|
||||
"d:displayname": {},
|
||||
"d:resourcetype": {},
|
||||
"d:current-user-privilege-set": {},
|
||||
"d:getetag": {},
|
||||
"c:supported-calendar-component-set": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCalendarQueryBody(start: string, end: string): string {
|
||||
return builder.build({
|
||||
"c:calendar-query": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"@_xmlns:c": "urn:ietf:params:xml:ns:caldav",
|
||||
"d:prop": {
|
||||
"d:getetag": {},
|
||||
},
|
||||
"c:filter": {
|
||||
"c:comp-filter": {
|
||||
"@_name": "VCALENDAR",
|
||||
"c:comp-filter": {
|
||||
"@_name": "VEVENT",
|
||||
"c:time-range": {
|
||||
"@_start": start,
|
||||
"@_end": end,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMkcalendarBody(displayName: string): string {
|
||||
return builder.build({
|
||||
"c:mkcalendar": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"@_xmlns:c": "urn:ietf:params:xml:ns:caldav",
|
||||
"d:set": {
|
||||
"d:prop": {
|
||||
"d:displayname": displayName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildProppatchDisplayNameBody(displayName: string): string {
|
||||
return builder.build({
|
||||
"d:propertyupdate": {
|
||||
"@_xmlns:d": "DAV:",
|
||||
"d:set": {
|
||||
"d:prop": {
|
||||
"d:displayname": displayName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function flattenProps(prop: Record<string, unknown>): DavPropMap {
|
||||
const output: DavPropMap = {};
|
||||
for (const [key, value] of Object.entries(prop)) {
|
||||
output[key] = value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseStatusCode(statusLine: string): number {
|
||||
const match = statusLine.match(/\s(\d{3})\s/);
|
||||
if (!match) {
|
||||
return 500;
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function ensureArray<T>(value: T | T[] | undefined): T[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
25
src/config.ts
Normal file
25
src/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const configSchema = z.object({
|
||||
CALDAV_BASE_URL: z.string().url(),
|
||||
MCP_HTTP_PORT: z.coerce.number().int().positive().default(3000),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
ALLOW_COOKIE_PASSTHROUGH: z.preprocess((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value === "true";
|
||||
}
|
||||
return false;
|
||||
}, z.boolean().default(false)),
|
||||
CALDAV_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
|
||||
CALDAV_RETRY_COUNT: z.coerce.number().int().min(0).max(5).default(1),
|
||||
EVENT_HREF_STRATEGY: z.enum(["uid"]).default("uid"),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
return configSchema.parse(env);
|
||||
}
|
||||
49
src/errors.ts
Normal file
49
src/errors.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export class CaldavHttpError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly method: string,
|
||||
public readonly url: string,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(`CalDAV request failed: ${method} ${url} -> ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {}
|
||||
|
||||
export class UnauthenticatedError extends Error {}
|
||||
|
||||
export function mapErrorToMcp(error: unknown): McpError {
|
||||
if (error instanceof McpError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return new McpError(ErrorCode.InvalidParams, error.message);
|
||||
}
|
||||
|
||||
if (error instanceof UnauthenticatedError) {
|
||||
return new McpError(ErrorCode.InvalidRequest, "Unauthenticated", { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof CaldavHttpError) {
|
||||
if (error.status === 400) return new McpError(ErrorCode.InvalidParams, "Invalid request to CalDAV server", { status: 400 });
|
||||
if (error.status === 401) return new McpError(ErrorCode.InvalidRequest, "Unauthenticated", { status: 401 });
|
||||
if (error.status === 403) return new McpError(ErrorCode.InvalidRequest, "Forbidden", { status: 403 });
|
||||
if (error.status === 404) return new McpError(ErrorCode.InvalidRequest, "Resource not found", { status: 404 });
|
||||
if (error.status === 409 || error.status === 412) return new McpError(ErrorCode.InvalidRequest, "Conflict or precondition failed", { status: error.status });
|
||||
if (error.status === 422) return new McpError(ErrorCode.InvalidParams, "Unprocessable calendar data", { status: 422 });
|
||||
if (error.status === 423) return new McpError(ErrorCode.InvalidRequest, "Resource is locked", { status: 423 });
|
||||
if (error.status === 424) return new McpError(ErrorCode.InvalidRequest, "Failed dependency", { status: 424 });
|
||||
if (error.status === 507) return new McpError(ErrorCode.InternalError, "Insufficient storage on upstream", { status: 507 });
|
||||
if (error.status >= 500) return new McpError(ErrorCode.InternalError, "CalDAV upstream unavailable", { status: error.status });
|
||||
return new McpError(ErrorCode.InvalidRequest, "CalDAV request failed", { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new McpError(ErrorCode.InternalError, error.message);
|
||||
}
|
||||
return new McpError(ErrorCode.InternalError, "Unknown error");
|
||||
}
|
||||
107
src/ics.ts
Normal file
107
src/ics.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ValidationError } from "./errors.js";
|
||||
|
||||
export interface EventInput {
|
||||
uid?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
dtstart: string;
|
||||
dtend?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export function ensureUid(ics: string): { ics: string; uid: string } {
|
||||
const existing = extractIcsField(ics, "UID");
|
||||
if (existing) {
|
||||
return { ics, uid: existing };
|
||||
}
|
||||
const uid = randomUUID();
|
||||
const injected = ics.replace("BEGIN:VEVENT", `BEGIN:VEVENT\nUID:${uid}`);
|
||||
return { ics: injected, uid };
|
||||
}
|
||||
|
||||
export function validateIcsEvent(ics: string): void {
|
||||
const uid = extractIcsField(ics, "UID");
|
||||
if (!uid) {
|
||||
throw new ValidationError("UID is required for VEVENT");
|
||||
}
|
||||
|
||||
const dtstart = extractIcsField(ics, "DTSTART");
|
||||
const dtend = extractIcsField(ics, "DTEND");
|
||||
if (!dtstart) {
|
||||
throw new ValidationError("DTSTART is required for VEVENT");
|
||||
}
|
||||
|
||||
if (dtend && normalizeDate(dtend) < normalizeDate(dtstart)) {
|
||||
throw new ValidationError("DTEND must be equal or after DTSTART");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildIcsFromStructured(input: EventInput): string {
|
||||
const uid = input.uid ?? randomUUID();
|
||||
if (input.dtend && normalizeDate(input.dtend) < normalizeDate(input.dtstart)) {
|
||||
throw new ValidationError("DTEND must be equal or after DTSTART");
|
||||
}
|
||||
|
||||
const dtstartLine = input.timezone ? `DTSTART;TZID=${input.timezone}:${input.dtstart}` : `DTSTART:${input.dtstart}`;
|
||||
const dtendLine = input.dtend
|
||||
? input.timezone
|
||||
? `DTEND;TZID=${input.timezone}:${input.dtend}`
|
||||
: `DTEND:${input.dtend}`
|
||||
: undefined;
|
||||
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//caldav-mcp//EN",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${uid}`,
|
||||
dtstartLine,
|
||||
dtendLine,
|
||||
input.summary ? `SUMMARY:${escapeText(input.summary)}` : undefined,
|
||||
input.description ? `DESCRIPTION:${escapeText(input.description)}` : undefined,
|
||||
input.location ? `LOCATION:${escapeText(input.location)}` : undefined,
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
].filter((line): line is string => Boolean(line));
|
||||
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
export function extractIcsField(ics: string, key: string): string | undefined {
|
||||
const lines = unfoldIcsLines(ics);
|
||||
for (const line of lines) {
|
||||
const upper = line.toUpperCase();
|
||||
if (!upper.startsWith(`${key.toUpperCase()}:`) && !upper.startsWith(`${key.toUpperCase()};`)) {
|
||||
continue;
|
||||
}
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) {
|
||||
continue;
|
||||
}
|
||||
return line.slice(idx + 1).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unfoldIcsLines(ics: string): string[] {
|
||||
const raw = ics.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
for (const line of raw) {
|
||||
if ((line.startsWith(" ") || line.startsWith("\t")) && output.length > 0) {
|
||||
output[output.length - 1] += line.slice(1);
|
||||
} else {
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeDate(value: string): string {
|
||||
return value.replace(/[-:]/g, "");
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
}
|
||||
117
src/index.ts
Normal file
117
src/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { CaldavClient } from "./caldav/client.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { Logger } from "./logger.js";
|
||||
import { registerTools } from "./tools.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = new Logger(config.LOG_LEVEL);
|
||||
|
||||
function buildMcpServer(): McpServer {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "caldav-mcp",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
logging: {},
|
||||
},
|
||||
instructions:
|
||||
"CalDAV MCP server with auth pass-through. Provide Authorization header from MCP client request.",
|
||||
},
|
||||
);
|
||||
|
||||
const client = new CaldavClient(config, logger);
|
||||
registerTools(server, { client, config, logger });
|
||||
return server;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname !== "/mcp") {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const correlationId = req.headers["x-request-id"] ?? randomUUID();
|
||||
logger.info("Incoming MCP request", { correlationId, path: url.pathname, method: req.method, headers: req.headers });
|
||||
|
||||
let parsedBody: unknown;
|
||||
try {
|
||||
parsedBody = await readJsonBody(req);
|
||||
} catch (error) {
|
||||
logger.warn("Invalid JSON body", { correlationId, error: error instanceof Error ? error.message : String(error) });
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32700, message: "Parse error" },
|
||||
id: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpServer = buildMcpServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await mcpServer.connect(transport);
|
||||
await transport.handleRequest(req, res, parsedBody);
|
||||
} catch (error) {
|
||||
logger.error("MCP transport error", { correlationId, error: error instanceof Error ? error.message : String(error) });
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await transport.close().catch(() => undefined);
|
||||
await mcpServer.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.MCP_HTTP_PORT, () => {
|
||||
logger.info("caldav-mcp listening", {
|
||||
port: config.MCP_HTTP_PORT,
|
||||
baseUrl: config.CALDAV_BASE_URL,
|
||||
});
|
||||
});
|
||||
|
||||
async function readJsonBody(req: import("node:http").IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
if (chunks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
58
src/logger.ts
Normal file
58
src/logger.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const levelWeight: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
export class Logger {
|
||||
constructor(private readonly level: LogLevel = "info") {}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>): void {
|
||||
this.log("debug", message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>): void {
|
||||
this.log("info", message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: Record<string, unknown>): void {
|
||||
this.log("warn", message, context);
|
||||
}
|
||||
|
||||
error(message: string, context?: Record<string, unknown>): void {
|
||||
this.log("error", message, context);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
|
||||
if (levelWeight[level] < levelWeight[this.level]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeContext = context ? redactSensitive(context) : undefined;
|
||||
const payload = safeContext ? ` ${JSON.stringify(safeContext)}` : "";
|
||||
process.stdout.write(`[${new Date().toISOString()}] ${level.toUpperCase()} ${message}${payload}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function redactSensitive(input: unknown): unknown {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(redactSensitive);
|
||||
}
|
||||
if (!input || typeof input !== "object") {
|
||||
return input;
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized.includes("authorization") || normalized.includes("cookie") || normalized.includes("token")) {
|
||||
output[key] = "<redacted>";
|
||||
continue;
|
||||
}
|
||||
output[key] = redactSensitive(value);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
244
src/tools.ts
Normal file
244
src/tools.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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 } };
|
||||
},
|
||||
);
|
||||
}
|
||||
35
src/util.ts
Normal file
35
src/util.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export function getHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() !== target) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCorrelationId(headers: Record<string, string | string[] | undefined>): string {
|
||||
return getHeader(headers, "x-request-id") ?? randomUUID();
|
||||
}
|
||||
|
||||
export function normalizeHref(href: string, baseUrl: string): string {
|
||||
const normalized = new URL(href, baseUrl);
|
||||
normalized.hash = "";
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
export function normalizeIdFromHref(href: string): string {
|
||||
const url = new URL(href);
|
||||
const trimmed = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
|
||||
return decodeURIComponent(trimmed.split("/").filter(Boolean).at(-1) ?? "");
|
||||
}
|
||||
|
||||
export function joinUrl(baseHref: string, child: string): string {
|
||||
return new URL(child, baseHref).toString();
|
||||
}
|
||||
16
tests/errors.test.ts
Normal file
16
tests/errors.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { CaldavHttpError, mapErrorToMcp } from "../src/errors.js";
|
||||
|
||||
describe("mapErrorToMcp", () => {
|
||||
it("maps 412 to invalid request conflict", () => {
|
||||
const error = mapErrorToMcp(new CaldavHttpError(412, "PUT", "https://example.com", ""));
|
||||
expect(error.code).toBe(ErrorCode.InvalidRequest);
|
||||
expect(error.message.toLowerCase()).toContain("conflict");
|
||||
});
|
||||
|
||||
it("maps 5xx to internal error", () => {
|
||||
const error = mapErrorToMcp(new CaldavHttpError(503, "PROPFIND", "https://example.com", ""));
|
||||
expect(error.code).toBe(ErrorCode.InternalError);
|
||||
});
|
||||
});
|
||||
17
tests/ics.test.ts
Normal file
17
tests/ics.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ensureUid, validateIcsEvent } from "../src/ics.js";
|
||||
import { ValidationError } from "../src/errors.js";
|
||||
|
||||
describe("ics helpers", () => {
|
||||
it("adds UID when missing", () => {
|
||||
const ics = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260101T100000Z\nDTEND:20260101T110000Z\nEND:VEVENT\nEND:VCALENDAR`;
|
||||
const output = ensureUid(ics);
|
||||
expect(output.uid).toBeTruthy();
|
||||
expect(output.ics).toContain(`UID:${output.uid}`);
|
||||
});
|
||||
|
||||
it("rejects DTEND before DTSTART", () => {
|
||||
const ics = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:abc\nDTSTART:20260102T100000Z\nDTEND:20260101T100000Z\nEND:VEVENT\nEND:VCALENDAR`;
|
||||
expect(() => validateIcsEvent(ics)).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
14
tests/util.test.ts
Normal file
14
tests/util.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeHref } from "../src/util.js";
|
||||
|
||||
describe("normalizeHref", () => {
|
||||
it("resolves relative href against base", () => {
|
||||
const href = normalizeHref("/caldav/user/Calendar/personal/", "https://caldav.example.com/caldav");
|
||||
expect(href).toBe("https://caldav.example.com/caldav/user/Calendar/personal/");
|
||||
});
|
||||
|
||||
it("strips URL fragment", () => {
|
||||
const href = normalizeHref("https://caldav.example.com/caldav/user/Calendar/personal/#fragment", "https://caldav.example.com/caldav");
|
||||
expect(href).toBe("https://caldav.example.com/caldav/user/Calendar/personal/");
|
||||
});
|
||||
});
|
||||
25
tests/xml.test.ts
Normal file
25
tests/xml.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMultiStatus } from "../src/caldav/xml.js";
|
||||
|
||||
describe("parseMultiStatus", () => {
|
||||
it("parses href and props from multistatus response", () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/SOGo/dav/user/Calendar/personal/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:displayname>Personal</d:displayname>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`;
|
||||
|
||||
const parsed = parseMultiStatus(xml);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].href).toBe("/SOGo/dav/user/Calendar/personal/");
|
||||
expect(parsed[0].statuses[0].status).toBe(200);
|
||||
expect(parsed[0].statuses[0].props.displayname).toBe("Personal");
|
||||
});
|
||||
});
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user