feat: CardDAV contact tools #2

Merged
brad merged 3 commits from carddav-contacts into main 2026-04-29 22:21:44 -07:00
Owner

Summary

Adds CardDAV contact tools to the existing calendar MCP, completing the second half of the dav-mcp scope.

What's new

Five contact tools — opt-in via CARDDAV_ADDRESSBOOK_URL:

  • search-contacts(query?, category?, limit?) — substring search across name, email, phone, org, role, notes, address; CATEGORIES filter
  • get-contact(uid) — single-contact lookup
  • create-contact({ name, ... }) — flat input schema, tool generates the UUID
  • update-contact(uid, ...) — partial update; preserves unmodeled vCard properties (PHOTO, X-* extensions, additional phones/emails) via line-level patching
  • delete-contact(uid)

Foundation:

  • src/carddav.ts — tsdav client with env-var fallback (CARDDAV_* → CALDAV_* for shared-server setups like Baikal)
  • src/vcard.ts — hand-rolled vCard 4.0 parse/build/patch helpers (RFC 6350)
  • src/index.ts — registers contact tools only when CARDDAV_ADDRESSBOOK_URL is set

Design choices

  • No upsert-contact tool. Name-matching ambiguity ("which Jacob?") is better resolved in conversation than in the server. The composition pattern is documented in CLAUDE.md: search-contacts → branch on result count → call create or update.
  • Hybrid schema: input is flat (one phone, one email, etc.) for simple call-sheet capture; output preserves multi-value structure when a contact has multiple phones / emails.
  • Patch updates are lossless for known properties via patchVCard, which rewrites only touched property lines and preserves the rest of the vCard verbatim.
  • Default-import for tsdav: tsx's resolver picks tsdav's browser bundle (no named exports) when using import { createDAVClient } from "tsdav". Default-import + destructure works in both tsx and plain Node ESM.

Test plan

  • npm test — 44 tests pass (18 vCard tests + 5 per-tool tests + existing calendar coverage)
  • npm run check — Biome clean
  • Smoke: bogus CARDDAV_BASE_URL produces clean "Failed to connect to CardDAV server" + exit 1
  • Smoke: CALDAV-only config still works (calendar tools register, CardDAV setup skipped)
  • Real Baikal test — pending Brad's deploy

🤖 Generated with Claude Code

## Summary Adds CardDAV contact tools to the existing calendar MCP, completing the second half of the dav-mcp scope. ### What's new **Five contact tools** — opt-in via `CARDDAV_ADDRESSBOOK_URL`: - `search-contacts(query?, category?, limit?)` — substring search across name, email, phone, org, role, notes, address; CATEGORIES filter - `get-contact(uid)` — single-contact lookup - `create-contact({ name, ... })` — flat input schema, tool generates the UUID - `update-contact(uid, ...)` — partial update; **preserves unmodeled vCard properties** (PHOTO, X-* extensions, additional phones/emails) via line-level patching - `delete-contact(uid)` **Foundation:** - `src/carddav.ts` — tsdav client with env-var fallback (CARDDAV_* → CALDAV_* for shared-server setups like Baikal) - `src/vcard.ts` — hand-rolled vCard 4.0 parse/build/patch helpers (RFC 6350) - `src/index.ts` — registers contact tools only when CARDDAV_ADDRESSBOOK_URL is set ### Design choices - **No `upsert-contact` tool**. Name-matching ambiguity ("which Jacob?") is better resolved in conversation than in the server. The composition pattern is documented in CLAUDE.md: `search-contacts` → branch on result count → call `create` or `update`. - **Hybrid schema**: input is flat (one phone, one email, etc.) for simple call-sheet capture; output preserves multi-value structure when a contact has multiple phones / emails. - **Patch updates are lossless** for known properties via `patchVCard`, which rewrites only touched property lines and preserves the rest of the vCard verbatim. - **Default-import for tsdav**: tsx's resolver picks tsdav's browser bundle (no named exports) when using `import { createDAVClient } from "tsdav"`. Default-import + destructure works in both tsx and plain Node ESM. ### Test plan - [x] `npm test` — 44 tests pass (18 vCard tests + 5 per-tool tests + existing calendar coverage) - [x] `npm run check` — Biome clean - [x] Smoke: bogus CARDDAV_BASE_URL produces clean "Failed to connect to CardDAV server" + exit 1 - [x] Smoke: CALDAV-only config still works (calendar tools register, CardDAV setup skipped) - [ ] Real Baikal test — pending Brad's deploy 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds five contact tools backed by tsdav and a hand-rolled vCard 4.0
parser/builder. Contacts module is opt-in via CARDDAV_ADDRESSBOOK_URL;
calendar and contact tools register independently per the modular
deployment pattern established in PR 1.

Tools:
- search-contacts: substring + category filter, default limit 50
- get-contact: lookup by UID
- create-contact: flat input schema; tool generates UUID
- update-contact: partial update; preserves unmodeled vCard properties
  (PHOTO, X-* extensions, additional phones/emails) via line-level patch
- delete-contact: by UID

Foundation:
- src/carddav.ts: tsdav client with env-var fallback (CARDDAV_* → CALDAV_*)
- src/vcard.ts: parseVCard / buildVCard / patchVCard helpers
- src/index.ts: optional CardDAV setup, registers contact tools when
  CARDDAV_ADDRESSBOOK_URL is set

Hybrid input/output schema: tools accept flat fields (single phone,
email, address) but parsing preserves multi-value structure on read.

Notes:
- Default-import for tsdav works around tsx resolving the browser bundle
  instead of the ESM bundle when using named imports
- No upsert-contact tool — composition pattern (search → create-or-update)
  documented in CLAUDE.md so the agent layer handles disambiguation

Tests:
- 18 vCard parser/builder/patcher tests including round-trip and
  unmodeled-property preservation
- Per-tool unit tests with mocked tsdav client

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four parallel adversarial reviews (security, vCard/CardDAV protocol
correctness, general correctness, design quality) surfaced a handful of
real issues. This commit addresses everything actionable.

Critical correctness:
- vcard.ts escapeValue: add \r escaping. Without it, an LLM-supplied
  string with embedded CR/LF could inject vCard structural lines
  (END:VCARD / BEGIN:VCARD) and corrupt the address book.
- vcard.ts unescapeValue: rewrite as single-pass state machine. The
  prior multi-step regex passes mangled values like "C:\\folder\\new"
  by treating the second \\n as escaped LF before resolving \\\\.
- vcard.ts patchVCard: TYPE-aware drop predicate. Patching `phone`
  previously dropped ALL TEL lines (including TYPE=WORK and TYPE=HOME),
  contradicting the README/CLAUDE.md promise to preserve additional
  phones. Now only drops TEL with TYPE=CELL or no TYPE; same approach
  for EMAIL and ADR.
- vcard.ts patchVCard: when patching `name`, only rewrite FN. Existing
  structured N (Smith;Jane;;;) is preserved rather than clobbered to
  N:Jane Smith;;;; — most clients use N for sort/index correctness.
- vcard.ts buildN: naive last-token-as-family split for new contacts
  (Apple Contacts displayed N:Jane Smith;;;; as last-name only).
- vcard.ts parseLine: strip Apple-style "item1." group prefix so
  patches don't leave duplicate phones from grouped properties.
- vcard.ts splitOnUnescapedComma: split CATEGORIES before unescape so
  a tag containing a literal comma (a\,b) round-trips as one tag.
- vcard.ts foldLine: count UTF-8 octets, not chars, when folding lines
  longer than 75 bytes — prevents bisecting multibyte sequences and
  emitting invalid UTF-8 (RFC 6350 §3.2 explicitly says octets).

Validation:
- schemas/contact-input.ts (new): shared safeText that rejects C0/DEL
  control chars; .min(1) on name and uid; YYYY-MM-DD regex on birthday.
- create-contact and update-contact use the shared schema instead of
  duplicating field definitions inline.

Defensive:
- carddav.ts readEnv: refuse to fall back to CalDAV credentials when
  the CardDAV target host differs from the CalDAV host. Set explicit
  CARDDAV_USERNAME/PASSWORD if you really mean cross-host.
- carddav.ts findVCardByUid: extract shared lookup helper. Throws on
  ambiguous match (>1 vCard with same UID) instead of silently
  picking one. get/update/delete contact tools now share this path.
- index.ts: log error.message rather than full Error object on
  startup connection failures (avoids any chance of dumping
  request/response bodies that may contain auth headers).

Tool descriptions:
- All contact tools now mention the address book is configured by
  CARDDAV_ADDRESSBOOK_URL (no per-call URL parameter).
- update-contact nudges agent toward search-contacts first if no UID.
- search-contacts notes that limit hits return server-defined order.

Tests:
- vcard.test.ts: 6 new tests covering the unescape state machine,
  TYPE-preserving patch, naive N decomposition, structured N
  preservation, group prefix stripping, CATEGORIES comma round-trip,
  CR-injection guard, and multibyte UTF-8 folding.
- carddav.test.ts (new): 3 tests for findVCardByUid covering match,
  not-found, and ambiguous-match cases.
- 58 tests pass total.

Defer to future iteration:
- vCard 3.0 vs 4.0 version selection (depends on Baikal version
  testing — verify with real deploy first)
- ADR multi-component input schema (current single-line goes into
  street; documented limitation)
- REV property emission (cosmetic; affects "recently modified" sort)
- Concurrent-write retry on 412 Precondition Failed
- Server-side filtering instead of fetch-all (Brad's address book
  is small enough that this is premature)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Update — adversarial review pass

Spawned four parallel reviewers (security, vCard/CardDAV protocol correctness, general correctness, design quality) against this branch and applied the actionable findings in commit 6dc31f4.

Critical bugs fixed (data-loss / corruption)

  • vCard injection via \rescapeValue was missing CR. An LLM-supplied string with embedded CRLF could splice in extra BEGIN:VCARD / END:VCARD lines and corrupt the address book.
  • Unescape order bug — multi-step regex unescapeValue mangled values like C:\folder\new (treated \\n as escaped LF before resolving \\\\). Rewritten as a single-pass state machine.
  • patchVCard dropped non-CELL phones — patching phone was deleting all TEL lines including TYPE=WORK/TYPE=HOME, contradicting the README promise. Now uses TYPE-aware drop predicate; same fix for email and address.
  • patchVCard clobbered structured N — patching name was rewriting N:Smith;Jane;;; to N:Jane Smith;;;;. Now name patch only touches FN; existing N is preserved.
  • CATEGORIES with literal commas — split-after-unescape lost commas embedded in tags. Now splits on unescaped commas first.
  • Apple item1.TEL group prefix — patches were leaving original grouped TEL alongside new ungrouped TEL → duplicate phones. Group prefix is now stripped on parse.

Defensive improvements

  • UTF-8 octet foldingfoldLine now counts bytes, not chars, so multibyte names/notes never bisect mid-sequence.
  • Naive N decomposition on buildMary Ann SmithN:Smith;Mary Ann;;; instead of putting the whole name in family slot.
  • Cross-host credential refusal — falling back from CALDAV_* to CARDDAV_* creds is now blocked when the URL hosts differ. Set explicit CARDDAV_USERNAME/PASSWORD if you really mean to send CalDAV creds to a different server.
  • Ambiguous UID detection — extracted findVCardByUid helper; throws on >1 hit instead of silently picking one.
  • Input validation — shared safeText schema rejects C0/DEL control chars; .min(1) on name and uid; YYYY-MM-DD regex on birthday.
  • Error logging — startup failures log error.message only.

Design improvements

  • Extracted src/schemas/contact-input.ts so create/update share field definitions.
  • Tool descriptions now mention CARDDAV_ADDRESSBOOK_URL is configured server-side; update-contact nudges toward search-contacts first; search-contacts notes server-defined ordering.

Tests

58 passing (up from 44). New coverage:

  • vCard: unescape state machine, TYPE-preserving patch, naive N split, N preservation on patch, group prefix, CATEGORIES comma round-trip, CR-injection guard, multibyte folding.
  • carddav: findVCardByUid match / not-found / ambiguous-match.

Deferred (not blockers)

  • vCard 4.0 vs 3.0 version selection — verify with real Baikal test first
  • ADR multi-component schema — single-line documented limitation
  • REV property emission — cosmetic
  • 412 Precondition Failed retry — single-user concurrency real risk is low
  • Server-side filtering vs fetch-all — Brad's address book size makes this premature
## Update — adversarial review pass Spawned four parallel reviewers (security, vCard/CardDAV protocol correctness, general correctness, design quality) against this branch and applied the actionable findings in commit 6dc31f4. ### Critical bugs fixed (data-loss / corruption) - **vCard injection via `\r`** — `escapeValue` was missing CR. An LLM-supplied string with embedded CRLF could splice in extra `BEGIN:VCARD` / `END:VCARD` lines and corrupt the address book. - **Unescape order bug** — multi-step regex `unescapeValue` mangled values like `C:\folder\new` (treated `\\n` as escaped LF before resolving `\\\\`). Rewritten as a single-pass state machine. - **patchVCard dropped non-CELL phones** — patching `phone` was deleting all `TEL` lines including `TYPE=WORK`/`TYPE=HOME`, contradicting the README promise. Now uses TYPE-aware drop predicate; same fix for `email` and `address`. - **patchVCard clobbered structured `N`** — patching `name` was rewriting `N:Smith;Jane;;;` to `N:Jane Smith;;;;`. Now `name` patch only touches FN; existing N is preserved. - **CATEGORIES with literal commas** — split-after-unescape lost commas embedded in tags. Now splits on unescaped commas first. - **Apple `item1.TEL` group prefix** — patches were leaving original grouped TEL alongside new ungrouped TEL → duplicate phones. Group prefix is now stripped on parse. ### Defensive improvements - **UTF-8 octet folding** — `foldLine` now counts bytes, not chars, so multibyte names/notes never bisect mid-sequence. - **Naive N decomposition on build** — `Mary Ann Smith` → `N:Smith;Mary Ann;;;` instead of putting the whole name in family slot. - **Cross-host credential refusal** — falling back from `CALDAV_*` to `CARDDAV_*` creds is now blocked when the URL hosts differ. Set explicit `CARDDAV_USERNAME`/`PASSWORD` if you really mean to send CalDAV creds to a different server. - **Ambiguous UID detection** — extracted `findVCardByUid` helper; throws on >1 hit instead of silently picking one. - **Input validation** — shared `safeText` schema rejects C0/DEL control chars; `.min(1)` on name and uid; YYYY-MM-DD regex on birthday. - **Error logging** — startup failures log `error.message` only. ### Design improvements - Extracted `src/schemas/contact-input.ts` so create/update share field definitions. - Tool descriptions now mention `CARDDAV_ADDRESSBOOK_URL` is configured server-side; `update-contact` nudges toward `search-contacts` first; `search-contacts` notes server-defined ordering. ### Tests 58 passing (up from 44). New coverage: - vCard: unescape state machine, TYPE-preserving patch, naive N split, N preservation on patch, group prefix, CATEGORIES comma round-trip, CR-injection guard, multibyte folding. - carddav: `findVCardByUid` match / not-found / ambiguous-match. ### Deferred (not blockers) - vCard 4.0 vs 3.0 version selection — verify with real Baikal test first - ADR multi-component schema — single-line documented limitation - REV property emission — cosmetic - 412 Precondition Failed retry — single-user concurrency real risk is low - Server-side filtering vs fetch-all — Brad's address book size makes this premature
Contacts-only deployments only need to set CARDDAV_ADDRESSBOOK_URL + creds;
requiring CARDDAV_BASE_URL on top is redundant since the address book URL
already carries the host. Fall back to deriving baseUrl from the address
book URL's origin when neither CARDDAV_BASE_URL nor CALDAV_BASE_URL is set.

Cross-host credential refusal still applies in the case where CalDAV is
configured at a different host than the CardDAV target.

Discovered when Brad's contacts MCP entry produced "Connection closed" —
the missing CARDDAV_BASE_URL in his config was throwing a startup error
before the MCP handshake could complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
brad merged commit e57ad18822 into main 2026-04-29 22:21:44 -07:00
brad deleted branch carddav-contacts 2026-04-29 22:21:44 -07:00
brad referenced this pull request from a commit 2026-04-29 22:21:44 -07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
brad/dav-mcp!2
No description provided.