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

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");
});
});