implement CalDAV MCP server v1 with streamable HTTP tools

This commit is contained in:
2026-03-20 15:47:06 +01:00
parent c92bf5c912
commit 4735b9ccf2
20 changed files with 4505 additions and 2 deletions

7
.env.template Normal file
View 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
View 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/

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}