Changelog
0.1.4 - 2026-10-22
Fixed
- Every value of a comma-separated property is read, not only the first. ical.js splits a multi-value property on its unescaped commas while parsing, and the readers asked it for the first value, so a card holding
CATEGORIES:Handwerker,Zubauwas listed, fetched and searched as if it saidHandwerkeralone (#34). A client that editscategoriesand writes the list back would have deleted the rest. The same reader cutNICKNAME:Bob,RobtoBobin both vCard versions, and a vCard 3.0NOTEwritten with an unescaped comma — ical.js treatsNOTEas multi-valued in 3.0 — up to that comma.search_contactsnow finds a contact by any of its values. Stored cards were never changed by this. - An escaped comma stays inside its category.
CATEGORIES:Smith\, Jones,lawis two categories,Smith, Jonesandlaw, which is how the vCard grammar reads it and howcategorieswrites it. It came back asSmithandJones, withlawlost to the bug above.
0.1.3 - 2026-09-08
Fixed
- A card wrapped in
<![CDATA[…]]>is read instead of counted as unreadable.address-datais astopNodesentry, so the parser hands back its raw source rather than its text — and the XML packaging comes with it. Open-Xchange (mailbox.org) wraps the card, so every card arrived beginning<![CDATA[BEGIN:VCARDand no vCard parser would touch it: an address book of 79 cards listed as empty, with a note saying 78 could not be parsed. The sections are now taken off and joined, including the split a server has to make around a card containing]]>. Entity references inside a section are left alone, because that is what a CDATA section means: a note that says&says five characters, and it used to arrive as one. - An indented response is read too.
trimValuesdoes not reach a stop node, so a server that pretty-prints its XML handed over\n BEGIN:VCARD…, and a vCard has to start atBEGIN:. Leading and trailing whitespace is packaging as much as the CDATA markers are.
Changed
- A CR or LF reference is decoded only where a real newline follows it. sabre/dav encodes the vCard's own line endings as
and has to: XML normalises a raw CR to LF on the way in, so escaping it is the only way to keep it. That shape — the reference immediately before the newline it stands for — is now the whole of the exception. A reference smuggled into a value has no real newline behind it, soFN:harmless EMAIL:x@y.zstays literal and invents no second property. The relaxation used to cover both code points everywhere in the card. caldav-mcp applies the same rule tocalendar-data; the two servers disagreed here, and only one of them could have been right. mcp-tool-allowlist0.2.2.
0.1.2 - 2026-09-07
The follow-ups from the caldav-mcp review of the same week, applied to the sibling: the same guard shape, the same property, one function moved to the library it belongs to.
Fixed
list_groupssurvives a card that shapes badly. The parser is lazy: a value it cannot decode throws on the read, not on the parse.list_contactshas guarded parsing and shaping together since 0.1.1;list_groupsguarded the parse only, so one such card took the whole listing down where an unparseable one was merely counted. Shaping now happens inside the same per-card guard and joins the same count, and the note says "could not be read" rather than "could not be parsed" because it now covers both. The three other places that guarded the parse alone — the local re-filter behindsearch_contacts, the group scan, and the member index — read the card inside the guard as well.
Changed
orderedResourceKeycomes from mcp-approval 0.8.2 instead of a local copy. Same function, same NUL-and-index prefix, one home. mcp-approval 0.8.1 also made a sealed dialog answer single-use; this release carries that pin.
Added
- A property test over the shape layer: for any card that parses, with any value in any of the thirty-one properties this server reads, in either vCard version, shaping does not throw, the result validates against the output schema, and nothing it returns carries a raw control character. The examples in the other files cover the values that were tried; this states the contract over every value the generator can spell.
0.1.1 - 2026-09-06
A security review of 0.1.0 — every module, the tests, the workflows and the image — with the findings fixed rather than reported. Nothing here needed a protocol change; every fix is behind the same seventeen tools.
Security
- Two quadratic regular expressions on text a card author or the server chooses.
base64Bytesmeasured aPHOTO's padding with/=*$/, which is retried from every position of a run of=— 80 000 of them cost two seconds inget_contact, a card at the 1 MiB read ceiling cost minutes, and anyone with write access to a shared address book can store one. The same shape inresourceNameOf(/[^/]*$/) ran on every<D:href>of every listing, so one response with a long segment held the process for as long as the server liked. Both arelastIndexOfnow,normalisePathandresourceUrlhad the same pattern and are loops, and an href longer than 8 KiB is dropped by the multistatus parser before anything walks it. A test file times each of them at its maximum size. - A prototype key in
PHOTO;TYPE=crashed every listing the card was in.mediaTypeOflooked the format up in an object literal, soTYPE=constructorreturnedObjectitself where a string was promised, and the projection threwinput.normalize is not a functionout oflist_contacts,search_contactsandget_contact— outside the guard that skips an unparseable card. The lookup usesObject.hasOwn, and a card that parses but cannot be shaped is now counted and skipped like one that does not parse. - Approval keys are bound to positions.
setResourceKeysorts its targets, which is right for a set and wrong for a tuple:update_groupspelled the name and the note identically, so a token issued for{name: "Team", note: "internal"}also executed{name: "internal", note: "Team"}, andmove_contactlisted source and destination together, so an approval to move a card from Work to Private also authorised moving a card of the same name from Private to Work. Every key is built withorderedResourceKey, which prefixes each part with its index. delete_contactno longer deletes a group card,move_contactno longer moves one, and araw_vcardthat is a group is refused bycreate_contactandupdate_contact. The first madedelete_groupa capability thatCARDDAV_DENY_TOOLS=delete_groupdid not remove; the second left a group whose every member was unresolvable under a dialog that said "move a contact"; the third turned a contact into a group under "replace a contact card". All four refuse before anybody is asked.CARDDAV_ADDRESSBOOKSentries are no longer quoted verbatim. An entry that matched nothing was printed in full — to stderr, which is the MCP client's log, and into thelist_address_booksanswer, which is the model's context — and an entry that matches nothing is exactly what a token pasted into the wrong line looks like. A path, a URL or a short segment name is quoted; anything else is described by its length. The startup warning about unencoded paths escapes the characters it fires on, and theELICITATIONandCARDDAV_MAX_CONTACTSdiagnostics cut and escape what they were given.export_contactsdefuses its text channel. Both channels carried the stored bytes, so aNOTEofcame back for a client to render and fetch.structuredContentis still the byte-exact export; the text block is a fenced, datamarked rendering with invisible characters removed and image markers broken, says so on its first line, and carries the injection warning.- Server-chosen strings in unmarked answers are cleaned.
get_server_inforeported the principal and home hrefs raw beside the header tokens it had been careful about, and a path segment ofsurvivesnew URL();list_changes,delete_contactanddelete_groupreported the collection path raw in this server's own voice; the address bookurlwas raw inlist_address_books. All go through the short-text cleaner. - The fenced text block is measured against the result budget. It was a third channel beside the two the budget measured, so a card just under the ceiling went out at roughly three times it. The budget can also drop from an array one level down now, so
get_groupon a large group is shortened rather than refused. - An upstream error body is quoted on one line, labelled and short, with the DAV precondition element name capped — a server could put two thousand characters with line breaks into the one answer that carries no untrusted marker. The body is read after the status is decided and under its own small ceiling, so a two-megabyte login page with a
401reports the401and the credentials hint instead of "the answer was larger than … bytes". - A refused login is remembered for ten seconds. Every request authenticates, and a model that reads "authentication refused" retries a tool annotated cheap and read-only; hosted providers lock an account after a handful of failed logins. The same refusal is repeated from memory inside the window, and says so.
- A server ETag with a control character, or longer than a kilobyte, is treated like a weak one. It went into
If-Matchunchecked, undici refused the header, and every write to that card failed asfetch failedfor as long as the server kept sending it. The write path now refuses with its usual sentence about a missing guard. - A refused redirect is explained. undici's
unexpected redirectreached the model asfetch failed; the error now says the server redirected, that carddav-mcp does not follow one, and thatCARDDAV_URLshould name the address the server redirects to. - Discovery is bounded. The registry keeps at most 256 address books and reports the overflow; a collection listed outside the home it was listed from is dropped, the way a card listed outside its collection already was.
get_contact_photohands over nothing it cannot name. Bytes that are not JPEG, PNG, GIF or WebP used to go out as animageblock labelledapplication/octet-stream; they are refused with a sentence.- URL-shaped fields lose their userinfo.
URL,IMPP, aPHOTOstored as a link and a group'sMEMBERreference can carryuser:password@, and onlyCARDDAV_URLwas redacted. - The publish job installs with
--ignore-scripts. It is the one job that holds an npm publishing token, and it ran every dependency's install hook while holding it; the audit job and the Dockerfile already installed this way.actions/dependency-review-actionruns on pull requests, and the GitHub release is created with--verify-tag. - The runtime image drops yarn and the lockfile. npm and corepack were already removed; yarn was not, and the lockfile is an inventory of the dependency tree that nothing in the image reads.
mcp-approval0.8.1: a sealed request state is single-use, so an answer to a confirmation dialog cannot be presented twice.
Changed
sync_tokenis validated on the way in with the same rule as on the way out (a URI, at most 512 characters), andlist_address_booksreturns it verbatim or not at all — it used to clean it, which cut a long token with an ellipsis and broke the next sync against a server doing nothing wrong.raw_vcardrefuses control characters other than line breaks, and a date has to exist: the 31st of February is refused, a yearless 29th is not.- oxlint's
suspiciouscategory is on,targetis ES2023 fortoSorted(), and the coverage thresholds rose to 95 / 87 / 96 / 96. docs/reference/tools.mdis written by hand again. It used to be generated from the registered tools, which kept it in step with the code at the price of a page nobody could edit:--checkcompared it byte for byte, so every line had to be derivable and a paragraph about how an endpoint really behaves had nowhere to go. A test now asserts what the generator guaranteed — the page documents exactly the tools that exist, marks exactly theessentialpreset, and marks exactly the tools that ask a person first — and leaves the prose to a person.icon-512.pngis generated bysvg-asset-set0.3.0 from the favicon rather than rendered by hand, sonpm run assets:checkcovers it and it cannot drift from the icon the docs site serves.
Added
- A test asserting the server warns about untrusted content in
instructions, the one channel a model reads before it calls anything. - The Glama badge, now that the listing exists. Eleventh and last but one in the row, which is the full set.
Fixed
hintForexplains avalid-sync-tokenrefusal: drop the token and start over without one.get_groupreports how many cards in the book share a UID and resolves the first the server listed, instead of silently re-pointing a member at the last.- The sanitisers cut their input before defusing image markers, which expanded two characters into thirty-one over the whole input first.
writeAddressfolds line breaks like every other serialiser, and the four reads that bypassedpropertyValuego through it.- The error message that refuses a group where a contact was meant no longer names a tool the
essentialpreset does not register. - The fake CardDAV server in the unit suite records request headers, refuses a PUT or DELETE without a guard, and can issue weak or malformed ETags, answer 412, and fail a scripted request — so the write guards are proven on the wire rather than assumed.
- Two overlapping labels in the architecture diagram.
PROPFIND · REPORT · PUTwas about 150px on one line in a 100px gap, so it was drawn across the right edge of the carddav-mcp box and into the CardDAV box, on top of the text already there; the three verbs are stacked now.Radicale, Baikal, Nextcloud, …overflowed both edges of its own box and is on two lines.
0.1.0 - 2026-09-06
Added
First release. An MCP server for CardDAV: 17 tools over contacts, groups and photos, on any server that speaks the standard.
- 17 tools, 10 read and 7 write.
CARDDAV_READ_ONLY=trueleaves the write tools unregistered rather than failing them, so they do not appear intools/listat all. - A listing is cheap and says so.
list_contactsretrieves only the summary properties, which is the difference between a few kilobytes and several megabytes once inline photos are involved — and every entry it returns is markedpartial, because a card retrieved that way is missing everything not asked for. The write path never accepts one: it issues its ownGETfor the card it is about to replace, every time. - Writes are read-modify-write over the parsed card, never a document rebuilt from the fields this server models. An
X-property some phone wrote in 2014, a photo nobody mentioned and every parameter survive an edit because they are never touched. Guarded withIf-Matchfrom the same read; neverIf-Match: *; a weak ETag is refused rather than dropped. - Both group conventions. vCard 4's
KIND:groupwithMEMBER, and Apple'sX-ADDRESSBOOKSERVER-KINDon top of vCard 3 — which is what Apple Contacts, Nextcloud and most of the installed base actually store. Both are read; the one written follows whatever the address book already uses, because a group in the other convention is invisible in the client the person is looking at. - A search is one request, not one per field. RFC 6352 gives
<C:filter>atestattribute defaulting toanyof, so several properties are matched in a singleaddressbook-query. Whatever comes back is checked again locally, because a server that filters only partially is indistinguishable from one that matched properly, and the result says which path it took. - Birthdays keep the year they have, and no more.
BDAY:--0415means "15 April, year unknown", which is what a phone writes when the year was never entered and a large share of real birthdays look like. It is reported as{month, day}rather than mapped to nothing or given an invented year. CARDDAV_ADDRESSBOOKSfences the server to named address books, enforced where an id is decoded rather than at the edge of each tool, so no tool can forget it — including a group membership change, which is the one place a fenced-off path could otherwise be named. A listing reports how many collections it withheld instead of quietly being shorter.- Ids are opaque and carry no origin. The host is rebuilt from
CARDDAV_URLon every decode, so a forged id cannot point this server at another server. Every join of a collection URL and a card name is checked on the resolved path: checking the name for a literal/is not the same check, because the URL parser normalises%2E%2Eand treats a backslash as a separator. - Contacts are treated as text a stranger wrote, because an address book rarely is written only by its owner — cards arrive by import from a phone, by sync from a directory, and from anyone with write access to a shared book. Names, organisations and notes are fenced with a per-call nonce and marked line by line; invisible and directional characters are removed; markdown image syntax is defused. Two of the injection shapes reported are specific to an address book, and the second is the reason contacts are worth attacking at all: a card asserting that somebody's number or account has changed does not need the model to run a tool, only to be believed.
- Nothing this server says quotes card content. Not the approval dialogs, not the error messages. Every value an error repeats is escaped, collapsed to one line and cut first, because an error reaches the model in the server's own voice, outside any fence.
- Photos are reported, not delivered.
get_contactsays a photo is there, its type and its size;get_contact_photoreturns the bytes as an image when asked for by name. A photo stored as a URI is reported and never fetched — that address was chosen by whoever wrote the card. - It never fetches an address somebody else chose. No tool takes a URL. Links returned by the server are pinned to the configured origin and refused if they carry credentials or a scheme this server does not speak — checked again at the point the credentials would leave the process.
CARDDAV_ALLOW_PLAINTEXT. A plainhttp://URL to a host that is not loopback refuses to start instead of printing a warning that a stdio deployment never shows. The switch lifts the refusal and is read strictly, likeCARDDAV_INSECURE_TLS.- Bearer or Basic authentication, RFC 6764 discovery from a server root or a collection URL, RFC 6578
sync-collectionaslist_changes, and a raw vCard export that keeps the properties this server does not model — because an export that dropped them would be a backup that silently loses data.