Skip to content

62 feat(mcp): report CardDAV credential state, and let schema_sdl return part of the schema - #63

Merged
radiosilence merged 2 commits into
mainfrom
62-session-carddav-and-sdl-types
Aug 17, 2026
Merged

62 feat(mcp): report CardDAV credential state, and let schema_sdl return part of the schema#63
radiosilence merged 2 commits into
mainfrom
62-session-carddav-and-sdl-types

Conversation

@radiosilence

@radiosilence radiosilence commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes #62 — both things it raised.

Session.carddavConfigured

contacts goes 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 where emails and sendEmail work fine, and capabilities can'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-Username and X-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 had carddavConfigured permanently 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.

carddavConfigured and contacts read the same resolved value. A reachability flag that can disagree with the operation it describes is worse than no flag — that's why request() gained a parameter and contacts gained a ctx.

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.

  • The graphql tool describes everyday mail inline: the queries, the EmailFilter tree, the common Email fields, 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_sdl takes types for 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 warnings and cargo fmt --check clean.

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 a nonce field and list-valued recipients, and a filter example naming an Archive mailbox that needn't exist.

Other things worth a reviewer's attention:

  • Backwards compatibility. schema_sdl previously took no arguments; rmcp reads absent arguments as {}, so existing callers still get the full schema. Pinned by schema_sdl_without_arguments_still_returns_everything.
  • Credential resolution: headers win over local config, each half falls back independently, an empty header isn't a credential, and a hosted deployment with neither reports incomplete.
  • CardDAV state: 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_addressable walks 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

radiosilence and others added 2 commits August 17, 2026 11:50
…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
radiosilence marked this pull request as ready for review August 17, 2026 11:05
@radiosilence
radiosilence merged commit 39ce787 into main Aug 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

This is things you found annoying

1 participant