From f7d11761df954b4634683e33d8774428a9a2b904 Mon Sep 17 00:00:00 2001 From: James Cleveland Date: Mon, 17 Aug 2026 11:50:00 +0100 Subject: [PATCH 1/2] 62 feat(mcp): report CardDAV credential state, and let schema_sdl return part of the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 35 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 13 +- src/mcp/graphql/mod.rs | 45 ++++++- src/mcp/graphql/query.rs | 18 ++- src/mcp/graphql/tests.rs | 146 ++++++++++++++++++++++- src/mcp/graphql/types.rs | 26 +++- src/mcp/mod.rs | 137 +++++++++++++++++++-- src/mcp/sdl.rs | 250 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 src/mcp/sdl.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 15424f4..b249281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## [3.4.0] - 2026-08-17 + +### Added + +- **`Session.carddavConfigured`.** Contacts go over CardDAV, which authenticates + with a username and an app password and rejects the API token everything else + uses — so `contacts` can fail on a connection where mail works perfectly. + `capabilities` could never report this: it lists what the JMAP server + advertises, and CardDAV is a separate protocol the handshake cannot see. The + only way to discover the gap was to run the query and fail, halfway through a + plan that assumed contacts were reachable. It reports whether both credentials + are present, not whether they work, and answers independently of `status`, + since credentials are local configuration and stay knowable when the token is + dead. Both it and `contacts` now read the same injected value rather than + loading the config separately, so the flag cannot disagree with the operation + it describes. + +- **`schema_sdl` takes a `types` list.** The full SDL is ~27KB, most of it the + doc comments that make it worth reading, and it was all-or-nothing — a session + that only sends mail paid for the contact and masked-email surface to find one + mutation, and paid again on every reconnect. `types: ["QueryRoot"]` or + `["MutationRoot"]` is usually enough to choose an operation, followed by the + argument types it names. Definitions come back whole and documented; the types + they reference do not, so name those too. An unrecognised name is reported in + a trailing SDL comment along with the full type list, rather than silently + returning a schema with a hole in it. Omitting `types` still returns + everything. + +- **Worked query shapes in the MCP server instructions.** The filter tree and + the PREVIEW→CONFIRM flow are the two things that cannot be guessed from a + field list, and they were costing a schema fetch each to discover. They are + scraped out of the instructions and executed by the test suite, so a documented + example cannot drift into being wrong — which it already had, in the draft of + this change. + ## [3.3.2] - 2026-07-26 ### Changed diff --git a/Cargo.lock b/Cargo.lock index e3df80b..a1245ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1380,7 +1380,7 @@ dependencies = [ [[package]] name = "fastmail-cli" -version = "3.3.2" +version = "3.4.0" dependencies = [ "anyhow", "askama", diff --git a/Cargo.toml b/Cargo.toml index 106cc00..bc07e7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastmail-cli" -version = "3.3.2" +version = "3.4.0" edition = "2024" description = "CLI for Fastmail's JMAP API" repository = "https://github.com/radiosilence/fastmail-cli" diff --git a/README.md b/README.md index acfa037..d0e5d06 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,7 @@ credentials. Queries that select any real field still authenticate as normal. it: ```graphql -{ session { status username primaryAccountId capabilities detail } } +{ session { status username primaryAccountId capabilities carddavConfigured detail } } ``` One `GET /jmap/session` — the handshake Fastmail only completes for a request it @@ -516,9 +516,18 @@ catch. When connected it also reports the accounts the token reaches and the capability URNs it was granted, which is what says whether masked email and sending are available at all. +`carddavConfigured` is the one thing there that `capabilities` cannot answer. +Capabilities are what the JMAP server advertises, and contacts go over CardDAV — +a separate protocol, with separate credentials, invisible to the handshake. So +without it the only way to find out that `contacts` is unavailable is to run it +and fail, halfway through a plan that assumed it. It reports whether a username +and app password are both present, not whether they are correct, and it answers +independently of `status`: credentials are local configuration, so a dead API +token doesn't make contact reachability unanswerable. + The MCP server exposes **2 tools** via a GraphQL interface: -- **`schema_sdl`** — returns the full GraphQL schema (SDL) so the LLM can discover all available operations +- **`schema_sdl`** — returns the GraphQL schema (SDL) so the LLM can discover available operations. Takes an optional `types` list (e.g. `["QueryRoot", "EmailFilter"]`) returning only those definitions, documentation included — the whole schema is ~27KB, most of it the doc comments that make it worth reading, and a session that only sends mail shouldn't pay for the contact surface to find one mutation. Named types come back whole but their references don't, so name those too; an unrecognised name is reported in a trailing comment alongside the type list rather than silently dropped - **`graphql`** — executes any GraphQL query or mutation against the Fastmail API This replaces the previous 18 individual tools with a composable interface. The LLM fetches the schema once, then constructs exactly the queries it needs — fetching multiple resources in a single round-trip, requesting only the fields it wants, and using typed arguments for filtering and pagination. diff --git a/src/mcp/graphql/mod.rs b/src/mcp/graphql/mod.rs index 218ab02..a05e107 100644 --- a/src/mcp/graphql/mod.rs +++ b/src/mcp/graphql/mod.rs @@ -24,6 +24,45 @@ pub type FastmailSchema = Schema>; +/// What the local config and environment supply for CardDAV. +/// +/// CardDAV authenticates with a username and an app password and rejects API +/// tokens, so neither half comes from the JMAP credential — contacts can be +/// unreachable on an otherwise perfectly good connection. +/// +/// Injected as request data rather than read inside a resolver so that +/// `Session.carddavConfigured` and the `contacts` query answer from the same +/// value. Reading the config in both places would let them disagree, and a +/// reachability flag that disagrees with the operation it describes is worse +/// than no flag. +#[derive(Clone, Debug, Default)] +pub struct CardDavCreds { + pub username: Option, + pub app_password: Option, +} + +impl CardDavCreds { + /// Read from `~/.config/fastmail-cli/config.toml` and the environment. + /// + /// Best-effort, like the default token: a hosted deployment ships no local + /// config, so both halves are `None` there and `contacts` is unavailable. + pub fn from_local_config() -> Self { + let Ok(config) = crate::config::Config::load() else { + return Self::default(); + }; + Self { + username: config.get_username().ok(), + app_password: config.get_app_password().ok(), + } + } + + /// Both halves present, so a CardDAV request can at least be attempted. + /// Says nothing about whether the credentials are *correct*. + pub fn is_complete(&self) -> bool { + self.username.is_some() && self.app_password.is_some() + } +} + /// Maximum selection-set nesting. The graph contains cycles by design — an /// email's thread contains emails, a mailbox's emails belong to mailboxes — so /// unbounded depth would let one query walk forever. 15 is far past any useful @@ -53,15 +92,17 @@ pub fn build_schema() -> FastmailSchema { } /// Build a GraphQL request carrying everything a resolver may need: the -/// authenticated JMAP client plus a fresh set of DataLoaders. +/// authenticated JMAP client, whatever CardDAV credentials exist locally, and a +/// fresh set of DataLoaders. /// /// Loaders are per request on purpose — their cache is then a request-scoped /// cache, so repeating a key inside one query is free while nothing is retained /// long enough to go stale. -pub fn request(query: &str, client: SharedClient) -> async_graphql::Request { +pub fn request(query: &str, client: SharedClient, carddav: CardDavCreds) -> async_graphql::Request { let loaders = loaders::Loaders::new(client.clone()); async_graphql::Request::new(query) .data(client) + .data(carddav) .data(loaders.email) .data(loaders.mailbox) .data(loaders.identity) diff --git a/src/mcp/graphql/query.rs b/src/mcp/graphql/query.rs index 93d7d52..5c68dd3 100644 --- a/src/mcp/graphql/query.rs +++ b/src/mcp/graphql/query.rs @@ -2,13 +2,13 @@ use async_graphql::{Context, Object, Result}; -use super::SharedClient; use super::connection::{ EmailConnection, ListConnection, PageArgs, emails_connection, page_complexity, paginate, }; use super::filter::{EmailFilter, EmailSort}; use super::loaders::{Emails, Identities, MaskedEmails, to_gql_error}; use super::types::*; +use super::{CardDavCreds, SharedClient}; pub struct QueryRoot; @@ -28,7 +28,8 @@ impl QueryRoot { /// stays distinguishable from a Fastmail outage, which is the split anyone /// acting on this needs. async fn session(&self, ctx: &Context<'_>) -> Result { - Ok(GqlSession::probe(ctx.data::()?).await) + let carddav_configured = ctx.data::()?.is_complete(); + Ok(GqlSession::probe(ctx.data::()?, carddav_configured).await) } /// List all mailboxes (folders) with unread counts. Start here to discover available folders. @@ -333,21 +334,26 @@ impl QueryRoot { ) } - /// Search contacts by name, email, or organization. Requires FASTMAIL_APP_PASSWORD. + /// Search contacts by name, email, or organization. + /// + /// Goes over CardDAV, not JMAP, so it needs a username and an app password + /// rather than the API token — check `session { carddavConfigured }` before + /// relying on it, since that is answerable without failing a query first. #[graphql(complexity = "page_complexity(first, last, child_complexity)")] async fn contacts( &self, + ctx: &Context<'_>, #[graphql(desc = "Search query — matches name, email, or organization")] query: String, after: Option, before: Option, first: Option, last: Option, ) -> Result> { - let config = crate::config::Config::load()?; - let username = config.get_username().map_err(|_| { + let creds = ctx.data::()?; + let username = creds.username.clone().ok_or_else(|| { async_graphql::Error::new("Username not configured. Set FASTMAIL_USERNAME env var.") })?; - let app_password = config.get_app_password().map_err(|_| { + let app_password = creds.app_password.clone().ok_or_else(|| { async_graphql::Error::new( "App password not configured. Set FASTMAIL_APP_PASSWORD env var (API tokens don't work for CardDAV).", ) diff --git a/src/mcp/graphql/tests.rs b/src/mcp/graphql/tests.rs index e811d72..e712783 100644 --- a/src/mcp/graphql/tests.rs +++ b/src/mcp/graphql/tests.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use super::{SharedClient, build_schema, request}; +use super::{CardDavCreds, SharedClient, build_schema, request}; use crate::jmap::JmapClient; /// One `Email/get`-shaped invocation recorded from a request body. @@ -264,8 +264,20 @@ fn client_for(server: &MockServer) -> SharedClient { } async fn run(server: &MockServer, query: &str) -> async_graphql::Response { + run_with_carddav(server, query, CardDavCreds::default()).await +} + +/// As [`run`], with CardDAV credentials supplied. Injected rather than read from +/// the environment so these tests don't depend on the machine running them. +async fn run_with_carddav( + server: &MockServer, + query: &str, + carddav: CardDavCreds, +) -> async_graphql::Response { let schema = build_schema(); - schema.execute(request(query, client_for(server))).await + schema + .execute(request(query, client_for(server), carddav)) + .await } #[tokio::test] @@ -362,7 +374,11 @@ async fn mailboxes_are_refetched_for_each_request() { for _ in 0..2 { let resp = schema - .execute(request("{ mailboxes { nodes { name } } }", client.clone())) + .execute(request( + "{ mailboxes { nodes { name } } }", + client.clone(), + CardDavCreds::default(), + )) .await; assert!(resp.errors.is_empty(), "{:?}", resp.errors); } @@ -721,6 +737,32 @@ async fn schema_prose_names_no_removed_construct() { } } +/// Every operation in a fenced block of the MCP server instructions. +/// +/// Scraped rather than copied here: these exist so a model can send them +/// without reading the schema first, which is worth nothing if they are wrong. +/// One operation per line, which is how they are written. +fn instruction_examples() -> Vec { + use rmcp::ServerHandler; + + let info = crate::mcp::FastmailMcp::http().get_info(); + let instructions = info.instructions.expect("server ships instructions"); + + instructions + .split("```") + .skip(1) + .step_by(2) + .flat_map(|block| { + block + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .collect::>() + }) + .collect() +} + #[tokio::test] async fn documented_examples_execute() { // The query shapes in the README and the MCP server instructions, run @@ -739,8 +781,14 @@ async fn documented_examples_execute() { first: 20) { totalCount nodes { subject size } } }", ]; - for query in documented { - let resp = run(&server, query).await; + let scraped = instruction_examples(); + assert!( + scraped.len() >= 4, + "expected the instructions to carry worked examples, found {scraped:?}" + ); + + for query in documented.iter().map(|q| q.to_string()).chain(scraped) { + let resp = run(&server, &query).await; assert!( resp.errors.is_empty(), "documented example failed: {:?}\nquery: {query}", @@ -1465,6 +1513,7 @@ async fn keyword_sort_without_keyword_is_rejected_before_any_call() { // ============ Session ============ const SESSION: &str = "{ session { status username primaryAccountId capabilities detail + carddavConfigured accounts { id name isPersonal isReadOnly } } }"; /// The `session` field, against a server mounting whatever the caller set up. @@ -1501,6 +1550,7 @@ async fn session_reports_the_authenticated_account() { "username": "test@example.com", "primaryAccountId": "acct1", "capabilities": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "carddavConfigured": false, "accounts": [ { "id": "acct1", "name": "test@example.com", "isPersonal": true, "isReadOnly": false }, @@ -1564,3 +1614,89 @@ async fn session_does_not_call_rate_limiting_a_credential_problem() { let server = session_endpoint(429).await; assert_eq!(session_of(&server).await["status"], "UNREACHABLE"); } + +fn carddav_creds() -> CardDavCreds { + CardDavCreds { + username: Some("test@example.com".into()), + app_password: Some("app-password".into()), + } +} + +/// `session { carddavConfigured }` against `carddav`, without touching mail. +async fn carddav_configured(server: &MockServer, carddav: CardDavCreds) -> bool { + let resp = run_with_carddav(server, "{ session { carddavConfigured } }", carddav).await; + assert!(resp.errors.is_empty(), "{:?}", resp.errors); + resp.data.into_json().unwrap()["session"]["carddavConfigured"] + .as_bool() + .expect("carddavConfigured must be a non-null Boolean") +} + +#[tokio::test] +async fn carddav_is_reported_configured_only_when_both_halves_are_present() { + let server = mock_server(1).await; + + assert!(carddav_configured(&server, carddav_creds()).await); + assert!( + !carddav_configured(&server, CardDavCreds::default()).await, + "neither half present" + ); + // CardDAV rejects API tokens, so a username on its own gets nowhere — + // reporting it as configured would send an agent down a path that fails. + assert!( + !carddav_configured( + &server, + CardDavCreds { + app_password: None, + ..carddav_creds() + } + ) + .await, + "username without an app password" + ); + assert!( + !carddav_configured( + &server, + CardDavCreds { + username: None, + ..carddav_creds() + } + ) + .await, + "app password without a username" + ); +} + +#[tokio::test] +async fn carddav_state_survives_a_dead_token() { + // The whole point of answering this on `Session`: the two credentials are + // unrelated, so a revoked API token must not make contact reachability + // unanswerable — that is precisely when a caller is re-planning. + let server = session_endpoint(401).await; + let resp = run_with_carddav( + &server, + "{ session { status carddavConfigured } }", + carddav_creds(), + ) + .await; + + let session = resp.data.into_json().unwrap()["session"].clone(); + assert_eq!(session["status"], "INVALID_CREDENTIALS"); + assert_eq!(session["carddavConfigured"], true); +} + +#[tokio::test] +async fn contacts_and_session_agree_about_missing_credentials() { + let server = mock_server(1).await; + assert!(!carddav_configured(&server, CardDavCreds::default()).await); + + // The flag exists to be trusted, so the operation it describes has to fail + // for the reason it advertised — and before any network call. + let resp = run(&server, "{ contacts(query: \"anyone\") { nodes { id } } }").await; + assert!( + resp.errors + .iter() + .any(|e| e.message.contains("Username not configured")), + "got {:?}", + resp.errors + ); +} diff --git a/src/mcp/graphql/types.rs b/src/mcp/graphql/types.rs index e16ebc7..a55bd13 100644 --- a/src/mcp/graphql/types.rs +++ b/src/mcp/graphql/types.rs @@ -676,6 +676,19 @@ pub struct GqlSession { /// missing here is one the token cannot use — masked email and submission /// are the two that vary by token scope. Empty unless connected. pub capabilities: Vec, + /// Whether `contacts` can run: CardDAV needs a username and an app + /// password, and rejects the API token everything else here uses. + /// + /// `capabilities` cannot answer this — it lists what the JMAP server + /// advertises, and CardDAV is a separate protocol invisible to it. So this + /// is the only way to find out short of running the query and failing. + /// Check it before planning look-up-a-contact-then-compose. + /// + /// True means both credentials are present, not that they are correct. + /// Independent of `status`: credentials are local configuration, so this + /// answers even when the token is dead. False in a hosted deployment, + /// which ships no local config. + pub carddav_configured: bool, /// Why it isn't connected, in human-readable form. Null when connected. pub detail: Option, } @@ -687,11 +700,11 @@ impl GqlSession { /// client per token for the life of the process, so a cached answer would /// keep reporting success long after a revocation — the precise case this /// exists to catch. One `GET /jmap/session`, no mail touched. - pub(crate) async fn probe(client: &super::SharedClient) -> Self { + pub(crate) async fn probe(client: &super::SharedClient, carddav_configured: bool) -> Self { use crate::error::Error; let mut client = client.lock().await; - match client.authenticate().await { + let mut session = match client.authenticate().await { Ok(session) => Self::from(session), Err(e @ Error::InvalidToken(_)) => { Self::disconnected(ConnectionStatus::InvalidCredentials, e) @@ -699,7 +712,12 @@ impl GqlSession { // Everything else is the server, not the credential. Telling // someone to re-authenticate over a 503 would be a lie. Err(e) => Self::disconnected(ConnectionStatus::Unreachable, e), - } + }; + // Set after the handshake rather than inside it: CardDAV credentials are + // local config that the handshake knows nothing about, and stay + // reportable when it fails. + session.carddav_configured = carddav_configured; + session } fn disconnected(status: ConnectionStatus, error: crate::error::Error) -> Self { @@ -709,6 +727,7 @@ impl GqlSession { primary_account_id: None, accounts: Vec::new(), capabilities: Vec::new(), + carddav_configured: false, detail: Some(error.to_string()), } } @@ -737,6 +756,7 @@ impl From<&Session> for GqlSession { primary_account_id: s.primary_account_id().map(str::to_owned), accounts, capabilities, + carddav_configured: false, detail: None, } } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 11a0f83..6979421 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,7 +1,7 @@ //! MCP (Model Context Protocol) server for Fastmail //! //! Exposes Fastmail functionality via two GraphQL tools: -//! - `schema_sdl` — returns the full GraphQL SDL for introspection +//! - `schema_sdl` — returns the GraphQL SDL, whole or sliced to named types //! - `graphql` — executes a GraphQL query/mutation use std::collections::HashMap; @@ -23,8 +23,9 @@ use crate::jmap::JmapClient; type ToolResult = std::result::Result; pub mod graphql; +mod sdl; -use graphql::{FastmailSchema, SharedClient}; +use graphql::{CardDavCreds, FastmailSchema, SharedClient}; /// Header carrying the per-request Fastmail API token in HTTP transport mode. /// A trusted upstream (the hosted service, after authenticating the user) sets @@ -89,6 +90,15 @@ pub struct GraphqlRequest { pub variables: Option, } +#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] +pub struct SchemaRequest { + /// Type names to return, e.g. `["QueryRoot", "EmailFilter"]`. Omit for the + /// whole schema, which is large. Each named type comes back whole, with its + /// documentation, but the types *it* references do not — name those too. + #[serde(default)] + pub types: Option>, +} + // ============ Server Implementation ============ #[derive(Clone)] @@ -99,6 +109,11 @@ pub struct FastmailMcp { /// stdio; over HTTP it is whatever [`local_token`] found, so `None` in a /// hosted deployment and every request must bring its own. default_token: Option, + /// CardDAV credentials from local config, read once at startup like + /// `default_token` — same lifecycle, same best-effort: absent in a hosted + /// deployment, where contacts are simply unavailable. Never per-request, + /// because unlike the token they cannot arrive in a header. + carddav: CardDavCreds, #[allow(dead_code)] // referenced by #[tool_handler] macro expansion tool_router: ToolRouter, } @@ -109,6 +124,7 @@ impl FastmailMcp { schema: Arc::new(graphql::build_schema()), clients: Arc::new(Mutex::new(HashMap::new())), default_token, + carddav: CardDavCreds::from_local_config(), tool_router: Self::tool_router(), } } @@ -151,10 +167,16 @@ impl FastmailMcp { impl FastmailMcp { #[tool( title = "Fastmail schema", - description = "Returns the full GraphQL SDL (Schema Definition Language) for the Fastmail API. Call this first to discover available queries, mutations, types, and their arguments. The schema includes all email, mailbox, identity, masked email, contact, and attachment operations." + description = "Returns the GraphQL SDL (Schema Definition Language) for the Fastmail API: every query, mutation, type and argument across email, mailboxes, identities, masked email, contacts and attachments. Pass `types` to return only the named types — the full schema is ~27KB, and `types: [\"QueryRoot\"]` or `[\"MutationRoot\"]` is usually enough to pick an operation, followed by the argument types it names." )] - async fn schema_sdl(&self) -> ToolResult { - Self::text_result(self.schema.sdl()) + async fn schema_sdl(&self, Parameters(req): Parameters) -> ToolResult { + let sdl = self.schema.sdl(); + match req.types { + // An explicit empty list means "no types", which is never what a + // caller wants; read it as the whole schema. + Some(types) if !types.is_empty() => Self::text_result(sdl::slice(&sdl, &types)), + _ => Self::text_result(sdl), + } } #[tool( @@ -177,7 +199,7 @@ impl FastmailMcp { Err(e) => return Self::error_result(format!("Fastmail authentication failed: {e}")), }; - let mut request = graphql::request(&req.query, client); + let mut request = graphql::request(&req.query, client, self.carddav.clone()); if let Some(ref vars) = req.variables { match serde_json::from_str::(vars) { @@ -219,9 +241,36 @@ impl ServerHandler for FastmailMcp { .with_server_info(server_info) .with_instructions( "Fastmail, as a GraphQL API.\n\n\ - Call `schema_sdl` once — every type, argument and per-field cost \ - is documented there — then use `graphql`. Variables go as a JSON \ + Every type, argument and per-field cost is documented in \ + `schema_sdl`, which takes a `types` list so you can read one \ + corner of it rather than all ~27KB. The shapes below cover most \ + sessions without reading any of it. Variables go as a JSON \ string.\n\n\ + ## Shapes\n\ + Filters are a tree — scalar fields AND together, `and`/`or`/`not` \ + nest:\n\ + ```\n\ + { emails(filter: {unread: true, inMailbox: \"INBOX\", \ + not: {hasKeyword: \"$answered\"}, \ + or: [{from: \"a@b.com\"}, {to: \"a@b.com\"}]}, first: 10) \ + { totalCount nodes { id subject from { email } } } }\n\ + ```\n\ + Sending is two calls, and the first one sends nothing. \ + Recipients are comma-separated strings, not lists, and the \ + token binds `to`/`subject`/`body` — repeat them unchanged or \ + CONFIRM is rejected:\n\ + ```\n\ + mutation { sendEmail(action: PREVIEW, to: \"a@b.com\", \ + subject: \"Hi\", body: \"...\") { preview confirmationToken } }\n\ + mutation { sendEmail(action: CONFIRM, to: \"a@b.com\", \ + subject: \"Hi\", body: \"...\", \ + confirmationToken: \"\") { success emailId } }\n\ + ```\n\ + Check credentials before planning around them — `contacts` needs \ + CardDAV, which the API token does not cover:\n\ + ```\n\ + { session { status carddavConfigured } }\n\ + ```\n\n\ ## Querying well\n\ - The graph is fully nested and everything below a list is \ batched, so ask for what you need in ONE query rather than \ @@ -343,7 +392,7 @@ async fn graphql_endpoint( Ok(client) => client, Err(e) => return error(format!("Fastmail authentication failed: {e}")), }; - graphql::request(&req.query, client) + graphql::request(&req.query, client, mcp.carddav.clone()) }; if let Some(vars) = req.variables { request = request.variables(async_graphql::Variables::from_json(vars)); @@ -473,6 +522,76 @@ mod tests { assert_eq!(got.as_deref(), Some("default-tok")); } + /// The text a tool call came back with. + fn text_of(result: CallToolResult) -> String { + result + .content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect() + } + + #[tokio::test] + async fn schema_sdl_without_arguments_still_returns_everything() { + // `types` was added to a tool that took no arguments at all, and rmcp + // reads absent arguments as `{}` — so an existing client that sends + // none must keep getting the whole schema. + let empty: SchemaRequest = serde_json::from_str("{}").unwrap(); + assert!(empty.types.is_none()); + + let sdl = text_of( + FastmailMcp::http() + .schema_sdl(Parameters(empty)) + .await + .unwrap(), + ); + assert!(sdl.contains("type QueryRoot {")); + assert!(sdl.contains("type Session {")); + assert!(sdl.contains("input EmailFilter {")); + } + + #[tokio::test] + async fn schema_sdl_with_types_returns_only_those() { + let mcp = FastmailMcp::http(); + let sliced = text_of( + mcp.schema_sdl(Parameters(SchemaRequest { + types: Some(vec!["Session".into()]), + })) + .await + .unwrap(), + ); + + assert!(sliced.contains("type Session {")); + assert!(!sliced.contains("input EmailFilter {")); + + let full = text_of( + mcp.schema_sdl(Parameters(SchemaRequest::default())) + .await + .unwrap(), + ); + assert!( + sliced.len() * 10 < full.len(), + "{} of {} is not a saving worth the argument", + sliced.len(), + full.len() + ); + } + + #[tokio::test] + async fn an_empty_types_list_is_read_as_the_whole_schema() { + // Never a useful request, and returning nothing would look like a bug + // in the schema rather than in the call. + let sdl = text_of( + FastmailMcp::http() + .schema_sdl(Parameters(SchemaRequest { + types: Some(Vec::new()), + })) + .await + .unwrap(), + ); + assert!(sdl.contains("type QueryRoot {")); + } + #[test] fn introspection_needs_no_token() { // What GraphiQL sends on load, plus the shapes around it. diff --git a/src/mcp/sdl.rs b/src/mcp/sdl.rs new file mode 100644 index 0000000..473dbfd --- /dev/null +++ b/src/mcp/sdl.rs @@ -0,0 +1,250 @@ +//! Slicing the GraphQL SDL down to the types a caller actually asked for. +//! +//! The full SDL is ~27KB, most of it doc comments — which are worth having, and +//! are exactly what makes it expensive. A session that only sends mail pays for +//! the whole contact and masked-email surface to find the one mutation it +//! needs, and pays again every time the connection is re-established. +//! +//! Text slicing rather than re-rendering from `async_graphql`'s registry: the +//! registry exposes no per-type printer, and hand-rolling one would drift from +//! whatever `Schema::sdl()` emits. Splitting the emitted SDL cannot drift, +//! because it *is* the emitted SDL. + +/// One top-level definition and the lines it spans, doc comment included. +struct Definition<'a> { + name: &'a str, + span: std::ops::Range, +} + +/// The name a top-level definition declares, or `None` for a line that starts +/// no definition. +fn definition_name(header: &str) -> Option<&str> { + let mut words = header.split_whitespace(); + match words.next()? { + "type" | "input" | "enum" | "scalar" | "union" | "interface" => { + words.next().map(|n| n.trim_end_matches('{')) + } + // Addressable under the name you'd write in a query. + "directive" => words.next().map(|n| n.split('(').next().unwrap_or(n)), + "schema" => Some("schema"), + _ => None, + } +} + +/// Split SDL into its top-level definitions, in the order they appear. +/// +/// Relies only on layout `Schema::sdl()` guarantees: definitions begin in column +/// zero, their bodies are indented, a block closes on a column-zero `}`, and a +/// doc comment sits immediately above the definition it describes. +fn definitions<'a>(lines: &[&'a str]) -> Vec> { + let mut defs = Vec::new(); + let mut i = 0; + + while i < lines.len() { + if lines[i].trim().is_empty() { + i += 1; + continue; + } + let start = i; + + // A column-zero doc block belongs to whatever follows it. + if lines[i] == "\"\"\"" { + i += 1; + while i < lines.len() && lines[i] != "\"\"\"" { + i += 1; + } + i += 1; + } + let Some(header) = lines.get(i) else { break }; + + let name = definition_name(header); + if header.ends_with('{') { + i += 1; + while i < lines.len() && lines[i] != "}" { + i += 1; + } + } + i += 1; + + if let Some(name) = name { + defs.push(Definition { + name, + span: start..i, + }); + } + } + + defs +} + +/// Every type name in the schema, in SDL order. `slice` reports these itself +/// when a name matches nothing; this exists so the tests can enumerate them. +#[cfg(test)] +pub fn type_names(sdl: &str) -> Vec { + let lines: Vec<&str> = sdl.lines().collect(); + definitions(&lines) + .iter() + .map(|d| d.name.to_string()) + .collect() +} + +/// The definitions named in `wanted`, in schema order. +/// +/// Matching is case-insensitive as a fallback: a model reaching for `session` +/// means `Session`, and a round trip to be told so helps nobody. +/// +/// Names that match nothing are reported in a trailing SDL comment along with +/// the full list of type names — the output stays valid SDL, and a typo doesn't +/// silently return a schema with a hole in it. +pub fn slice(sdl: &str, wanted: &[String]) -> String { + let lines: Vec<&str> = sdl.lines().collect(); + let defs = definitions(&lines); + + let resolve = |want: &str| { + defs.iter() + .find(|d| d.name == want) + .or_else(|| defs.iter().find(|d| d.name.eq_ignore_ascii_case(want))) + }; + + let mut spans: Vec> = Vec::new(); + let mut unknown: Vec<&str> = Vec::new(); + for want in wanted { + match resolve(want) { + // Duplicates in `wanted` shouldn't duplicate the output. + Some(def) if !spans.contains(&def.span) => spans.push(def.span.clone()), + Some(_) => {} + None => unknown.push(want), + } + } + // Schema order, not request order: the SDL reads as a schema either way, and + // this keeps the output stable across differently-ordered requests. + spans.sort_by_key(|s| s.start); + + let mut out: Vec = spans + .into_iter() + .map(|span| lines[span].join("\n")) + .collect(); + + if !unknown.is_empty() { + out.push(format!( + "# No such type: {}.\n# The schema defines: {}.", + unknown.join(", "), + defs.iter().map(|d| d.name).collect::>().join(", ") + )); + } + + out.join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::graphql::build_schema; + + fn sdl() -> String { + build_schema().sdl() + } + + #[test] + fn slices_out_one_type_with_its_doc_comment() { + let out = slice(&sdl(), &["Session".into()]); + + assert!( + out.starts_with("\"\"\""), + "doc comment must come along: {out}" + ); + assert!(out.contains("Who the token authenticates as")); + assert!(out.contains("type Session {")); + assert!(out.trim_end().ends_with('}'), "block must be closed: {out}"); + // The point of the whole exercise. + assert!( + !out.contains("type Email "), + "sliced SDL leaked other types" + ); + } + + #[test] + fn a_slice_is_a_small_fraction_of_the_whole() { + let full = sdl(); + let out = slice(&full, &["Session".into(), "ConnectionStatus".into()]); + assert!( + out.len() * 10 < full.len(), + "expected a big saving, got {} of {}", + out.len(), + full.len() + ); + } + + #[test] + fn returns_definitions_in_schema_order_however_they_were_asked_for() { + let full = sdl(); + let forwards = slice(&full, &["Account".into(), "Session".into()]); + let backwards = slice(&full, &["Session".into(), "Account".into()]); + assert_eq!(forwards, backwards); + assert!(forwards.find("type Account").unwrap() < forwards.find("type Session").unwrap()); + } + + #[test] + fn handles_inputs_enums_and_the_roots() { + for name in [ + "EmailFilter", + "EmailSortProperty", + "QueryRoot", + "MutationRoot", + ] { + let out = slice(&sdl(), &[name.into()]); + assert!( + out.contains(&format!(" {name} {{")), + "missing {name}: {out}" + ); + assert!(out.trim_end().ends_with('}'), "unterminated {name}"); + } + } + + #[test] + fn a_repeated_name_is_returned_once() { + let out = slice(&sdl(), &["Session".into(), "Session".into()]); + assert_eq!(out.matches("type Session {").count(), 1); + } + + #[test] + fn a_wrong_case_name_still_resolves() { + assert!(slice(&sdl(), &["session".into()]).contains("type Session {")); + } + + #[test] + fn an_unknown_name_is_reported_rather_than_silently_dropped() { + let out = slice(&sdl(), &["Session".into(), "Emial".into()]); + + assert!( + out.contains("type Session {"), + "the valid half must survive" + ); + assert!(out.contains("# No such type: Emial."), "got {out}"); + // The recovery path: the names it could have meant. + assert!(out.contains("Email")); + } + + #[test] + fn every_definition_in_the_schema_is_addressable() { + let full = sdl(); + for name in type_names(&full) { + let out = slice(&full, std::slice::from_ref(&name)); + assert!( + !out.contains("# No such type"), + "{name} is in the schema but not addressable" + ); + } + } + + #[test] + fn asking_for_everything_reconstructs_the_schema() { + let full = sdl(); + let out = slice(&full, &type_names(&full)); + // Not byte-identical — blank-line runs between definitions collapse — + // but every definition must be present and whole. + for line in full.lines().filter(|l| definition_name(l).is_some()) { + assert!(out.contains(line), "lost: {line}"); + } + } +} From 4c8a40c3b89ff500a07c65799490996112bae630 Mon Sep 17 00:00:00 2001 From: James Cleveland Date: Mon, 17 Aug 2026 12:00:09 +0100 Subject: [PATCH 2/2] 62 refactor(mcp): resolve CardDAV credentials per request, inline the common schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 56 ++++++---- README.md | 42 ++++++-- src/mcp/graphql/mod.rs | 8 +- src/mcp/graphql/tests.rs | 79 +++++++++++--- src/mcp/mod.rs | 228 +++++++++++++++++++++++++++++++-------- 5 files changed, 323 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b249281..595a981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,28 +12,42 @@ only way to discover the gap was to run the query and fail, halfway through a plan that assumed contacts were reachable. It reports whether both credentials are present, not whether they work, and answers independently of `status`, - since credentials are local configuration and stay knowable when the token is - dead. Both it and `contacts` now read the same injected value rather than - loading the config separately, so the flag cannot disagree with the operation - it describes. - -- **`schema_sdl` takes a `types` list.** The full SDL is ~27KB, most of it the - doc comments that make it worth reading, and it was all-or-nothing — a session - that only sends mail paid for the contact and masked-email surface to find one - mutation, and paid again on every reconnect. `types: ["QueryRoot"]` or - `["MutationRoot"]` is usually enough to choose an operation, followed by the - argument types it names. Definitions come back whole and documented; the types + since they are resolved per request and stay knowable when the token is dead. + Both it and `contacts` read the same resolved value rather than loading the + config separately, so the flag cannot disagree with the operation it + describes. + +- **`X-Fastmail-Username` and `X-Fastmail-App-Password` headers.** CardDAV needs + its own credentials, so a hosted deployment could never offer contacts at all + — one bearer token cannot cover two protocols. They resolve exactly like + `X-Fastmail-Token`: the request's header first, local config after, each half + independently, so a header-supplied username is never silently completed with + the host's own password. + +### Changed + +- **The `graphql` tool describes everyday mail itself, so most sessions never + fetch the schema.** The SDL is ~27KB, most of it the doc comments that make it + worth reading, and it used to be the only way to learn anything — so reading + mail cost a 27KB fetch first, and cost it again whenever the connection + dropped. The tool description now carries a slimmed schema: the queries, the + `EmailFilter` tree, the common `Email` fields, the connection shape and the + PREVIEW→CONFIRM flow, plus worked examples. It names what it does *not* cover, + so the remaining cases know to ask rather than guess. + +- **`schema_sdl` takes a `types` list**, for those remaining cases: + `["MutationRoot"]` or `["Attachment", "MaskedEmail"]` is a few hundred bytes + rather than the lot. Definitions come back whole and documented; the types they reference do not, so name those too. An unrecognised name is reported in - a trailing SDL comment along with the full type list, rather than silently - returning a schema with a hole in it. Omitting `types` still returns - everything. - -- **Worked query shapes in the MCP server instructions.** The filter tree and - the PREVIEW→CONFIRM flow are the two things that cannot be guessed from a - field list, and they were costing a schema fetch each to discover. They are - scraped out of the instructions and executed by the test suite, so a documented - example cannot drift into being wrong — which it already had, in the draft of - this change. + a trailing SDL comment with the names that do exist, rather than returning a + schema with a hole in it. Omitting `types` still returns everything, and a + client that sends no arguments at all is unaffected. + + Both halves of that are held down by tests: the worked examples are scraped + out of the published tool descriptions and executed against the real schema, + and every field name in the inlined sketch must exist in it. A cheat sheet + that outlives a rename is worse than no cheat sheet — the first draft of this + one had already invented a `nonce` field and list-valued recipients. ## [3.3.2] - 2026-07-26 diff --git a/README.md b/README.md index d0e5d06..a68e364 100644 --- a/README.md +++ b/README.md @@ -469,14 +469,27 @@ MCP JSON-RPC, which it doesn't. That is why GraphiQL needs its own route rather than pointing at the MCP one. Both share the schema, the client cache and the credential resolution below, so the IDE sees exactly what a model sees. -**Token resolution is the same everywhere:** the request's `X-Fastmail-Token` -header wins, otherwise the configured token (or `FASTMAIL_API_TOKEN`) is used. +**Credential resolution is the same everywhere:** the request's own header +wins, otherwise the local config (or the matching environment variable) is used. Running it yourself, that means your own credentials with no ceremony. In a -hosted deployment there is no local token, so the fallback is absent and every -request must carry the header, injected by a trusted upstream after it has +hosted deployment there is no local config, so the fallback is absent and every +request must carry its headers, injected by a trusted upstream after it has authenticated the caller. Authenticated JMAP clients are cached per token, so the JMAP session handshake runs once per distinct token rather than per call. +| Header | Falls back to | Needed for | +| -------------------------- | ----------------------- | ----------------------- | +| `X-Fastmail-Token` | `FASTMAIL_API_TOKEN` | everything over JMAP | +| `X-Fastmail-Username` | `FASTMAIL_USERNAME` | contacts (CardDAV) | +| `X-Fastmail-App-Password` | `FASTMAIL_APP_PASSWORD` | contacts (CardDAV) | + +Contacts take two headers rather than riding on the token because CardDAV is a +separate protocol that rejects API tokens outright. Each resolves on its own: a +request carrying a username header but no password gets exactly that — half a +credential, which `session { carddavConfigured }` reports as `false` — rather +than quietly completing itself from the host's local config and mixing two +users together. + Do **not** expose this to the internet without such an auth layer in front — the header is trusted unconditionally. Equally, do not run it with local credentials present on a non-loopback address: anything that can reach the port @@ -527,8 +540,25 @@ token doesn't make contact reachability unanswerable. The MCP server exposes **2 tools** via a GraphQL interface: -- **`schema_sdl`** — returns the GraphQL schema (SDL) so the LLM can discover available operations. Takes an optional `types` list (e.g. `["QueryRoot", "EmailFilter"]`) returning only those definitions, documentation included — the whole schema is ~27KB, most of it the doc comments that make it worth reading, and a session that only sends mail shouldn't pay for the contact surface to find one mutation. Named types come back whole but their references don't, so name those too; an unrecognised name is reported in a trailing comment alongside the type list rather than silently dropped -- **`graphql`** — executes any GraphQL query or mutation against the Fastmail API +- **`graphql`** — executes any GraphQL query or mutation. Its description carries a slimmed schema for everyday mail: the queries, the `EmailFilter` tree, the common `Email` fields, the connection shape and the PREVIEW→CONFIRM send flow +- **`schema_sdl`** — the full SDL, with an optional `types` list (e.g. `["MutationRoot", "Attachment"]`) returning only those definitions + +The split is about round trips. The SDL is ~27KB, most of it the doc comments +that make it worth reading, and it was previously the only way to learn +anything — so reading mail cost a 27KB fetch first, and cost it again whenever +the connection dropped. The common case is now answered where the model is +already looking, and `schema_sdl` is for what the sketch explicitly says it +doesn't cover: attachment payloads, masked email, contacts, identities, +`moveEmail`, `markAsRead`, `markAsSpam`, and the remaining filter and sort +options. `types` keeps that second hop small too. Named types come back whole +but their references don't, so name those as well; an unrecognised name is +reported alongside the list of names that do exist, rather than silently +dropped. + +Both the worked examples and the inlined sketch are checked by the test suite — +the examples are executed against the real schema, and every field name in the +sketch must exist in it. A cheat sheet that outlives a rename is worse than no +cheat sheet. This replaces the previous 18 individual tools with a composable interface. The LLM fetches the schema once, then constructs exactly the queries it needs — fetching multiple resources in a single round-trip, requesting only the fields it wants, and using typed arguments for filtering and pagination. diff --git a/src/mcp/graphql/mod.rs b/src/mcp/graphql/mod.rs index a05e107..5f398df 100644 --- a/src/mcp/graphql/mod.rs +++ b/src/mcp/graphql/mod.rs @@ -24,7 +24,7 @@ pub type FastmailSchema = Schema>; -/// What the local config and environment supply for CardDAV. +/// The credentials `contacts` needs. /// /// CardDAV authenticates with a username and an app password and rejects API /// tokens, so neither half comes from the JMAP credential — contacts can be @@ -44,8 +44,10 @@ pub struct CardDavCreds { impl CardDavCreds { /// Read from `~/.config/fastmail-cli/config.toml` and the environment. /// - /// Best-effort, like the default token: a hosted deployment ships no local - /// config, so both halves are `None` there and `contacts` is unavailable. + /// The fallback for when a request carries no credential headers, exactly + /// as [`crate::mcp::local_token`] is for the token: running this yourself + /// picks up your own credentials, while a hosted deployment ships no local + /// config and every request must bring its own. pub fn from_local_config() -> Self { let Ok(config) = crate::config::Config::load() else { return Self::default(); diff --git a/src/mcp/graphql/tests.rs b/src/mcp/graphql/tests.rs index e712783..5f18716 100644 --- a/src/mcp/graphql/tests.rs +++ b/src/mcp/graphql/tests.rs @@ -737,21 +737,28 @@ async fn schema_prose_names_no_removed_construct() { } } -/// Every operation in a fenced block of the MCP server instructions. +/// Every operation in a fenced block of the advertised tool descriptions and +/// server instructions. /// -/// Scraped rather than copied here: these exist so a model can send them -/// without reading the schema first, which is worth nothing if they are wrong. -/// One operation per line, which is how they are written. -fn instruction_examples() -> Vec { +/// Scraped from what the server actually publishes, rather than copied here: +/// these exist so a model can compose a query without fetching the schema +/// first, which is worth nothing if they are wrong. One operation per line, +/// which is how they are written. +fn documented_shapes() -> Vec { use rmcp::ServerHandler; - let info = crate::mcp::FastmailMcp::http().get_info(); - let instructions = info.instructions.expect("server ships instructions"); + let mcp = crate::mcp::FastmailMcp::http(); + let published: Vec = mcp + .tool_router + .list_all() + .into_iter() + .filter_map(|t| t.description.map(|d| d.to_string())) + .chain(mcp.get_info().instructions) + .collect(); - instructions - .split("```") - .skip(1) - .step_by(2) + published + .iter() + .flat_map(|text| text.split("```").skip(1).step_by(2)) .flat_map(|block| { block .lines() @@ -781,10 +788,10 @@ async fn documented_examples_execute() { first: 20) { totalCount nodes { subject size } } }", ]; - let scraped = instruction_examples(); + let scraped = documented_shapes(); assert!( - scraped.len() >= 4, - "expected the instructions to carry worked examples, found {scraped:?}" + scraped.len() >= 3, + "expected the tool descriptions to carry worked examples, found {scraped:?}" ); for query in documented.iter().map(|q| q.to_string()).chain(scraped) { @@ -797,6 +804,50 @@ async fn documented_examples_execute() { } } +#[tokio::test] +async fn the_inlined_schema_sketch_names_only_real_fields() { + // The `graphql` description inlines a slimmed schema so everyday mail needs + // no `schema_sdl` round trip at all. That trade only holds while it is + // true — a sketch that outlives a rename sends models at fields that no + // longer exist, which is worse than making them fetch the real thing. + let mcp = crate::mcp::FastmailMcp::http(); + let sdl = build_schema().sdl(); + let description = mcp + .tool_router + .list_all() + .into_iter() + .find(|t| t.name == "graphql") + .and_then(|t| t.description) + .expect("the graphql tool is described"); + + // Only the indented signature lines, minus their `#` comments — the prose + // around them mentions things like "CardDAV" that are deliberately not + // schema names. + let mut checked = 0; + for line in description.lines().filter(|l| l.starts_with(" ")) { + let code = line.split('#').next().unwrap_or_default(); + for ident in code.split(|c: char| !c.is_alphanumeric() && c != '_') { + // Field and argument names only: types are capitalised, and + // `true`/`false` are values rather than anything to look up. + if ident.len() < 2 + || !ident.starts_with(|c: char| c.is_ascii_lowercase()) + || matches!(ident, "true" | "false" | "null") + { + continue; + } + checked += 1; + assert!( + sdl.contains(&format!("{ident}:")) || sdl.contains(&format!("{ident}(")), + "the inlined sketch names `{ident}`, which the schema does not define" + ); + } + } + assert!( + checked > 50, + "expected a real sketch, only checked {checked}" + ); +} + #[tokio::test] async fn repeated_email_ids_are_deduplicated() { let server = mock_server(1).await; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 6979421..28753d0 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -33,6 +33,15 @@ use graphql::{CardDavCreds, FastmailSchema, SharedClient}; /// token is used instead. pub const TOKEN_HEADER: &str = "x-fastmail-token"; +/// Headers carrying the per-request CardDAV credentials, set by the same +/// trusted upstream as [`TOKEN_HEADER`]. +/// +/// Separate from the token because CardDAV is a separate protocol that rejects +/// API tokens outright — one bearer value cannot cover both, which is why the +/// gateway declares three credential fields for this backend rather than one. +pub const USERNAME_HEADER: &str = "x-fastmail-username"; +pub const APP_PASSWORD_HEADER: &str = "x-fastmail-app-password"; + /// Cache of authenticated JMAP clients keyed by Fastmail token, so we don't /// re-run the JMAP session handshake on every tool call. Shared across sessions. type ClientCache = Arc>>; @@ -62,11 +71,30 @@ async fn client_for(cache: &ClientCache, token: &str) -> anyhow::Result, default: Option<&str>) -> Option { + header_value(headers, TOKEN_HEADER).or_else(|| default.map(str::to_owned)) +} + +fn header_value(headers: Option<&http::HeaderMap>, name: &str) -> Option { headers - .and_then(|h| h.get(TOKEN_HEADER)) + .and_then(|h| h.get(name)) .and_then(|v| v.to_str().ok()) + .filter(|v| !v.is_empty()) .map(str::to_owned) - .or_else(|| default.map(str::to_owned)) +} + +/// Per-request CardDAV credentials, resolved like [`resolve_token`]: the +/// request's own headers first, then whatever local config supplied. +/// +/// Each half falls back independently. A deployment that stores a username but +/// no app password should get exactly that — half-configured, which +/// `carddavConfigured` reports as false — rather than silently mixing one +/// user's header with another's local default. +fn resolve_carddav(headers: Option<&http::HeaderMap>, default: &CardDavCreds) -> CardDavCreds { + CardDavCreds { + username: header_value(headers, USERNAME_HEADER).or_else(|| default.username.clone()), + app_password: header_value(headers, APP_PASSWORD_HEADER) + .or_else(|| default.app_password.clone()), + } } /// The Fastmail token to use when no request header supplies one: the local @@ -109,11 +137,10 @@ pub struct FastmailMcp { /// stdio; over HTTP it is whatever [`local_token`] found, so `None` in a /// hosted deployment and every request must bring its own. default_token: Option, - /// CardDAV credentials from local config, read once at startup like - /// `default_token` — same lifecycle, same best-effort: absent in a hosted - /// deployment, where contacts are simply unavailable. Never per-request, - /// because unlike the token they cannot arrive in a header. - carddav: CardDavCreds, + /// CardDAV credentials used when a request carries no credential headers. + /// Exactly `default_token`'s counterpart: your own config over stdio, + /// nothing in a hosted deployment. + default_carddav: CardDavCreds, #[allow(dead_code)] // referenced by #[tool_handler] macro expansion tool_router: ToolRouter, } @@ -124,7 +151,7 @@ impl FastmailMcp { schema: Arc::new(graphql::build_schema()), clients: Arc::new(Mutex::new(HashMap::new())), default_token, - carddav: CardDavCreds::from_local_config(), + default_carddav: CardDavCreds::from_local_config(), tool_router: Self::tool_router(), } } @@ -147,11 +174,19 @@ impl FastmailMcp { /// Resolve the Fastmail token for this request: the per-request header if /// present (HTTP), otherwise the configured default (stdio). fn resolve_token(&self, ctx: &RequestContext) -> Option { - let headers = ctx - .extensions + resolve_token(Self::headers(ctx), self.default_token.as_deref()) + } + + /// CardDAV credentials for this request — headers first, local config after. + fn resolve_carddav(&self, ctx: &RequestContext) -> CardDavCreds { + resolve_carddav(Self::headers(ctx), &self.default_carddav) + } + + /// The HTTP headers behind this request, absent over stdio. + fn headers(ctx: &RequestContext) -> Option<&http::HeaderMap> { + ctx.extensions .get::() - .map(|p| &p.headers); - resolve_token(headers, self.default_token.as_deref()) + .map(|p| &p.headers) } fn text_result(text: impl Into) -> ToolResult { @@ -167,7 +202,11 @@ impl FastmailMcp { impl FastmailMcp { #[tool( title = "Fastmail schema", - description = "Returns the GraphQL SDL (Schema Definition Language) for the Fastmail API: every query, mutation, type and argument across email, mailboxes, identities, masked email, contacts and attachments. Pass `types` to return only the named types — the full schema is ~27KB, and `types: [\"QueryRoot\"]` or `[\"MutationRoot\"]` is usually enough to pick an operation, followed by the argument types it names." + description = "The full GraphQL SDL for the Fastmail API, with documentation on every type, argument and per-field cost.\n\ +\n\ +You do not need this for everyday mail — the `graphql` tool's own description already carries the queries, filters, fields and send flow for that. Reach for this when you want something it lists as not covered (attachment payloads, masked email, contacts, identities, moveEmail, markAsRead, markAsSpam, the remaining filter and sort options), or the exact cost of a field.\n\ +\n\ +Pass `types` to fetch only what you need: the whole schema is ~27KB, and `types: [\"MutationRoot\"]` or `[\"Attachment\", \"MaskedEmail\"]` is usually a few hundred bytes. Named types come back whole and documented, but the types they reference do not — name those too. An unrecognised name is reported back with the list of names that do exist. Omit `types` for the lot." )] async fn schema_sdl(&self, Parameters(req): Parameters) -> ToolResult { let sdl = self.schema.sdl(); @@ -181,7 +220,56 @@ impl FastmailMcp { #[tool( title = "Fastmail", - description = "Execute a GraphQL query or mutation against the Fastmail API. Use `schema_sdl` first to discover the schema. Supports all email operations: listing mailboxes, reading/searching emails, sending/replying/forwarding (with preview/confirm pattern), managing masked emails, downloading attachments, and searching contacts. Pass variables as a JSON string." + description = "Execute a GraphQL query or mutation against the Fastmail API. Variables go as a JSON string. + +Everyday mail is covered below — call `schema_sdl` only for what isn't. + +QUERIES + session: Session! # { status carddavConfigured username } + mailboxes(first: Int): MailboxConnection! + mailbox(name: String!): Mailbox # name or role — \"INBOX\", \"sent\", \"drafts\" + emails(filter: EmailFilter, sort: [EmailSort!], collapseThreads: Boolean, + first: Int, after: String): EmailConnection! + email(id: String!): Email + thread(emailId: String!): Thread! # { total emails { nodes { ... } } } + +EmailFilter — scalars on one object AND together, and/or/not nest arbitrarily: + text from to cc subject body: String # text searches all of them + inMailbox: String # name or role + inMailboxOtherThan: [String!] + unread flagged hasAttachment: Boolean + before after: String # YYYY-MM-DD or ISO 8601 + hasKeyword notKeyword: String # e.g. \"$answered\", \"$draft\" + and: [EmailFilter!] or: [EmailFilter!] not: EmailFilter + +Email fields: id subject preview textBody htmlBody receivedAt sentAt size + from to cc bcc { name email } isUnread isFlagged isDraft hasAttachment + mailboxes { name role } thread { total } attachments { nodes { name size } } + +Connections: `nodes` for items, first/last/after/before to page (default 25, +max 100), cursors are IDs, `pageInfo { hasNextPage endCursor }`. `totalCount` +is only computed when selected. + +MUTATIONS — sendEmail, replyToEmail and forwardEmail all take +`action: PREVIEW | CONFIRM | DRAFT`. PREVIEW sends nothing and returns a +confirmationToken; CONFIRM repeats the same to/subject/body plus that token, and +is rejected if they differ. Recipients are comma-separated strings, not lists. + sendEmail(action: SendAction!, to: String!, subject: String!, body: String!, + cc: String, bcc: String, from: String, htmlBody: String, + confirmationToken: String): ComposeResult! + ComposeResult { success emailId preview confirmationToken error } + +EXAMPLES +``` +{ emails(filter: {unread: true, inMailbox: \"INBOX\", not: {hasKeyword: \"$answered\"}, or: [{from: \"a@b.com\"}, {to: \"a@b.com\"}]}, first: 10) { totalCount nodes { id subject from { email } } } } +mutation { sendEmail(action: PREVIEW, to: \"a@b.com\", subject: \"Hi\", body: \"...\") { preview confirmationToken } } +mutation { sendEmail(action: CONFIRM, to: \"a@b.com\", subject: \"Hi\", body: \"...\", confirmationToken: \"\") { success emailId } } +``` + +NOT LISTED ABOVE — ask `schema_sdl` for these rather than guessing: attachment +payloads (base64/image/text), masked email, contacts and contact CRUD (CardDAV, +so check `session { carddavConfigured }` first), identities, moveEmail, +markAsRead, markAsSpam, and the remaining filter and sort options." )] async fn graphql( &self, @@ -199,7 +287,7 @@ impl FastmailMcp { Err(e) => return Self::error_result(format!("Fastmail authentication failed: {e}")), }; - let mut request = graphql::request(&req.query, client, self.carddav.clone()); + let mut request = graphql::request(&req.query, client, self.resolve_carddav(&ctx)); if let Some(ref vars) = req.variables { match serde_json::from_str::(vars) { @@ -241,36 +329,15 @@ impl ServerHandler for FastmailMcp { .with_server_info(server_info) .with_instructions( "Fastmail, as a GraphQL API.\n\n\ - Every type, argument and per-field cost is documented in \ - `schema_sdl`, which takes a `types` list so you can read one \ - corner of it rather than all ~27KB. The shapes below cover most \ - sessions without reading any of it. Variables go as a JSON \ - string.\n\n\ - ## Shapes\n\ - Filters are a tree — scalar fields AND together, `and`/`or`/`not` \ - nest:\n\ - ```\n\ - { emails(filter: {unread: true, inMailbox: \"INBOX\", \ - not: {hasKeyword: \"$answered\"}, \ - or: [{from: \"a@b.com\"}, {to: \"a@b.com\"}]}, first: 10) \ - { totalCount nodes { id subject from { email } } } }\n\ - ```\n\ - Sending is two calls, and the first one sends nothing. \ - Recipients are comma-separated strings, not lists, and the \ - token binds `to`/`subject`/`body` — repeat them unchanged or \ - CONFIRM is rejected:\n\ - ```\n\ - mutation { sendEmail(action: PREVIEW, to: \"a@b.com\", \ - subject: \"Hi\", body: \"...\") { preview confirmationToken } }\n\ - mutation { sendEmail(action: CONFIRM, to: \"a@b.com\", \ - subject: \"Hi\", body: \"...\", \ - confirmationToken: \"\") { success emailId } }\n\ - ```\n\ - Check credentials before planning around them — `contacts` needs \ - CardDAV, which the API token does not cover:\n\ - ```\n\ - { session { status carddavConfigured } }\n\ - ```\n\n\ + The `graphql` tool's description carries the queries, filters, \ + fields and send flow for everyday mail, so most sessions need no \ + schema fetch at all. `schema_sdl` has the rest, and takes a \ + `types` list so you can read one corner of it rather than all \ + ~27KB. Variables go as a JSON string.\n\n\ + Contacts are the one thing to check before planning around: they \ + go over CardDAV, which the API token does not cover, and \ + `{ session { status carddavConfigured } }` answers it without \ + failing a query first.\n\n\ ## Querying well\n\ - The graph is fully nested and everything below a list is \ batched, so ask for what you need in ONE query rather than \ @@ -392,7 +459,11 @@ async fn graphql_endpoint( Ok(client) => client, Err(e) => return error(format!("Fastmail authentication failed: {e}")), }; - graphql::request(&req.query, client, mcp.carddav.clone()) + graphql::request( + &req.query, + client, + resolve_carddav(Some(&headers), &mcp.default_carddav), + ) }; if let Some(vars) = req.variables { request = request.variables(async_graphql::Variables::from_json(vars)); @@ -522,6 +593,71 @@ mod tests { assert_eq!(got.as_deref(), Some("default-tok")); } + fn carddav_headers(username: Option<&str>, app_password: Option<&str>) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + if let Some(u) = username { + headers.insert(USERNAME_HEADER, u.parse().unwrap()); + } + if let Some(p) = app_password { + headers.insert(APP_PASSWORD_HEADER, p.parse().unwrap()); + } + headers + } + + fn local_carddav() -> CardDavCreds { + CardDavCreds { + username: Some("local@example.com".into()), + app_password: Some("local-password".into()), + } + } + + #[test] + fn carddav_headers_win_over_local_config() { + // The hosted path: the gateway injects one user's credentials, and they + // must not be shadowed by whatever the host machine happens to hold. + let headers = carddav_headers(Some("hosted@example.com"), Some("hosted-password")); + let got = resolve_carddav(Some(&headers), &local_carddav()); + + assert_eq!(got.username.as_deref(), Some("hosted@example.com")); + assert_eq!(got.app_password.as_deref(), Some("hosted-password")); + } + + #[test] + fn carddav_falls_back_to_local_config_over_stdio() { + let got = resolve_carddav(None, &local_carddav()); + assert_eq!(got.username.as_deref(), Some("local@example.com")); + assert!(got.is_complete()); + } + + #[test] + fn a_hosted_deployment_with_no_carddav_credentials_reports_incomplete() { + // No headers, no local config — `contacts` is genuinely unavailable, + // and `carddavConfigured` must say so rather than half-claiming it. + let got = resolve_carddav(Some(&http::HeaderMap::new()), &CardDavCreds::default()); + assert!(!got.is_complete()); + assert!(got.username.is_none() && got.app_password.is_none()); + } + + #[test] + fn each_half_of_the_carddav_credential_falls_back_on_its_own() { + // A username header with no password header is half a credential, and + // completing it from local config would mix two users together. + let headers = carddav_headers(Some("hosted@example.com"), None); + let got = resolve_carddav(Some(&headers), &CardDavCreds::default()); + + assert_eq!(got.username.as_deref(), Some("hosted@example.com")); + assert!(got.app_password.is_none()); + assert!(!got.is_complete()); + } + + #[test] + fn an_empty_credential_header_is_not_a_credential() { + // The gateway skips a field the user left blank, but a proxy that sends + // the header empty must not read as "configured". + let headers = carddav_headers(Some(""), Some("")); + assert!(!resolve_carddav(Some(&headers), &CardDavCreds::default()).is_complete()); + } + /// The text a tool call came back with. fn text_of(result: CallToolResult) -> String { result