62 feat(mcp): report CardDAV credential state, and let schema_sdl return part of the schema - #63
Merged
Merged
Conversation
…urn part of the schema Two things #62 found while using the MCP server, both about discovering something before committing to a plan rather than after failing one. `contacts` needs CardDAV, which authenticates with a username and an app password and rejects the API token everything else uses — so it can fail in a session where mail works fine. `capabilities` cannot report that: it lists what the JMAP server advertises, and CardDAV is a separate protocol the handshake never sees. The only way to find the gap was to run the query and fail halfway through look-up-contact-then-compose, and recover by scraping the address out of an email filter. `Session.carddavConfigured` answers it in the call that already establishes connection state. It reports that both credentials are present, not that they work, and it answers independently of `status`, since credentials are local config and stay knowable when the token is dead — which is exactly when something is re-planning. Both it and `contacts` now read one injected `CardDavCreds` rather than each loading the config itself. A reachability flag that can disagree with the operation it describes is worse than no flag, and injecting it also takes the config read off the resolver, so the tests stop depending on the machine running them. The SDL is ~27KB and was all-or-nothing. Most of that is the doc comments, which are the reason it's worth reading and the reason it's expensive — so the fix is narrowing, not trimming. `schema_sdl` takes `types`, returning those definitions whole and documented; the types they reference are not pulled in, since QueryRoot transitively reaches nearly everything. An unrecognised name comes back in a trailing SDL comment with the full type list, rather than silently returning a schema with a hole in it. Omitting `types` still returns everything, and rmcp reads absent arguments as `{}`, so existing callers are unaffected — pinned by a test, since that is the regression that would matter. Slicing is textual over the emitted SDL rather than a re-render from async_graphql's registry: there is no per-type printer, and hand-rolling one would drift from whatever `Schema::sdl()` emits. The instructions also gained the two shapes that cannot be guessed from a field list — the filter tree and PREVIEW→CONFIRM — which is the cheaper half of what #62 suggested. They are scraped out of the instructions and executed by the suite, so they cannot drift into being wrong. They had already: the first draft invented a `nonce` field and list-valued recipients. Closes #62 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… common schema Two corrections to the first pass, both from how the gateway actually works. CardDAV credentials were read from local config once at startup, which is only ever right when you run this yourself. The gateway injects per-user credentials as headers and strips whatever the client sent, so a hosted deployment would have had carddavConfigured permanently false and contacts permanently unavailable — the exact gap the field exists to report, made permanent. They now resolve like the token does: X-Fastmail-Username and X-Fastmail-App-Password first, local config after. Each half falls back on its own. A request carrying a username header and no password is half a credential, and completing it from the host's config would mix two users together; carddavConfigured reports false, which is the truth. The `types` argument alone still left the 90% case paying a round trip to learn what everyday mail looks like. So the `graphql` tool now describes that inline — queries, the EmailFilter tree, common Email fields, connection shape, PREVIEW→CONFIRM — and names what it doesn't cover so the rest knows to ask. `schema_sdl` keeps `types` for those: attachment payloads, masked email, contacts, identities, moveEmail, markAsRead, markAsSpam. No fetch for the common path, the whole schema still reachable for the rest. An inlined schema is a lie waiting to happen, so both halves are tested: the worked examples are scraped from the published tool descriptions and executed against the real schema, and every field name in the sketch must exist in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radiosilence
marked this pull request as ready for review
August 17, 2026 11:05
This was referenced Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #62 — both things it raised.
Session.carddavConfiguredcontactsgoes over CardDAV, which authenticates with a username and an app password and rejects the API token everything else uses. So it can fail in a session whereemailsandsendEmailwork fine, andcapabilitiescan't warn you: it lists what the JMAP server advertises, and CardDAV is a separate protocol the handshake never sees. The only way to discover the gap was to run the query and fail — halfway through look-up-contact-then-compose.The new field answers it in the call that already establishes connection state. It reports that both credentials are present, not that they work, and it answers independently of
status, since it's resolved per request and stays knowable when the token is dead — which is exactly when a caller is re-planning.CardDAV credentials now resolve per request, from
X-Fastmail-UsernameandX-Fastmail-App-Password, falling back to local config exactly as the token does. This is load-bearing rather than tidy-up: read once at startup, a hosted deployment would have hadcarddavConfiguredpermanently false and contacts permanently unavailable — the precise gap the field exists to report, made permanent. Each half falls back on its own, so a header-supplied username is never silently completed with the host's own password.The gateway side is radiosilence/mcp-gateway#45, which declares the two new fields so a deployed instance actually receives them.
carddavConfiguredandcontactsread the same resolved value. A reachability flag that can disagree with the operation it describes is worse than no flag — that's whyrequest()gained a parameter andcontactsgained actx.Not paying 27KB to read your mail
The SDL is ~27KB, and it used to be the only way to learn anything, so reading mail cost a full fetch first — and cost it again whenever the connection dropped.
graphqltool describes everyday mail inline: the queries, theEmailFiltertree, the commonEmailfields, the connection shape, and the PREVIEW→CONFIRM send flow, plus worked examples. It also names what it doesn't cover, so the remaining cases know to ask rather than guess.schema_sdltakestypesfor those cases —["MutationRoot"]or["Attachment", "MaskedEmail"]is a few hundred bytes instead of the lot. Named definitions come back whole and documented; types they reference don't, so name those too. An unrecognised name comes back with the names that do exist, rather than a schema with a hole in it.Slicing is textual over the emitted SDL rather than a re-render from async_graphql's registry: there's no per-type printer, and hand-rolling one would drift from whatever
Schema::sdl()emits.Verifying
cargo test— 195 pass.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.An inlined schema is a lie waiting to happen, so both halves are held down by tests. The worked examples are scraped out of the published tool descriptions and executed against the real schema; every field name in the inlined sketch must exist in it (
the_inlined_schema_sketch_names_only_real_fields, 50+ identifiers). This is not decoration — it caught the first draft inventing anoncefield and list-valued recipients, and a filter example naming anArchivemailbox that needn't exist.Other things worth a reviewer's attention:
schema_sdlpreviously took no arguments; rmcp reads absent arguments as{}, so existing callers still get the full schema. Pinned byschema_sdl_without_arguments_still_returns_everything.carddav_state_survives_a_dead_token(401 on JMAP, contacts still reported),contacts_and_session_agree_about_missing_credentials(the flag and the operation can't drift apart).every_definition_in_the_schema_is_addressablewalks every type name and asserts each one slices out.Version bumped 3.3.2 → 3.4.0; changelog and README updated.
🤖 Generated with Claude Code