fix(mcp): emit SEP-2549 cache hints so HTTP clients accept list results - #129
ThomasDalla wants to merge 2 commits into
Conversation
Servers built on rmcp 3.1.0 omit `ttlMs`/`cacheScope`, which MCP protocol revision 2026-07-28 requires on `tools/list`, `prompts/list`, `resources/list` and `resources/read` (`CacheableResult`). Spec-strict clients reject the whole result, so the server is unusable over Streamable HTTP before any tool is called. Two changes are needed, because the known upstream fix does not cover this crate's code: 1. Bump `rmcp` `=3.1.0` -> `=3.1.4`. modelcontextprotocol/rust-sdk#1114 fixed the same defect for `#[tool_handler]`/`#[prompt_handler]` in rmcp-macros 3.1.1. 2. Set the fields at the four hand-written handlers. yarr does not use the handler macros, so the macro fix alone changes nothing here: `list_tools`, `list_resources`, `read_resource` and `list_prompts` all build their result with `..Default::default()`, leaving `ttl_ms`/`cache_scope` as `None`, which serializes as absent. Only HTTP is affected in practice: rmcp's stdio path emits `resultType: "complete"`, which lets clients apply their own default backfill, while the streamable-HTTP path omits it, so nothing rescues the missing fields there. This is orthogonal to `YARR_MCP_TOOL_MODE` -- codemode and flat fail identically over HTTP and both work over stdio. Backward compatible: clients negotiating older protocol versions still receive the previous wire format.
|
Thank you for this — it is a genuinely excellent bug report and fix. The write-up did most of the work for me: the root-cause analysis is exactly right, and the transport × tool-mode matrix is what made the failure legible. I had not realised that our hand-written I reviewed this in depth, including running your branch locally and driving the real HTTP stack. Everything below is evidence I gathered; where I was wrong about something, I have said so. Verified on my side
1.
|
…ion, scope private Addresses the maintainer review on dinglebear-ai#129: - resources/templates/list was still uncovered (the blocking gap): rmcp's default impl returns both cache hints unset, so a strict 2026-07-28 client calling it still hit the rejection this PR exists to remove. Now overridden the same way as the other four handlers. - cacheScope is now Private, not rmcp's Public default: yarr is commonly deployed in flat tool mode behind a shared gateway/cache, where Public would let an intermediary serve one caller's tools/list or schema resources/read to a different, unauthenticated caller. - The hints are now gated on the caller's negotiated protocol version (>= 2026-07-28), mirroring the exact condition rmcp's own #[tool_handler]/#[prompt_handler] macros use. A small local trait (CacheableResult) + a single with_cache_hints(result, ctx, ttl_ms) helper in rmcp_server.rs replaces four separate call sites across two modules with two different TTL constants; prompts.rs goes back to returning plain data. LIST_RESULT_TTL_MS is renamed CACHEABLE_RESULT_TTL_MS since it also governs read_resource, not just list results. - New wire-level test (src/server/routes_tests/cache_hints_tests.rs) exercises all five cacheable methods through the real router/transport, asserting the exact ttlMs per method and cacheScope=private for a 2026-07-28 caller, and that a legacy caller gets neither field. Replaces the narrower prompts.rs unit test that only checked list_prompts in isolation. - CHANGELOG entry under [Unreleased] > Fixed. cargo fmt --check / cargo clippy --all-targets -- -D warnings / cargo test all clean (603 lib tests). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the thorough review — all of it made the fix better. Pushed a follow-up commit ( #1 (blocking) — #2 — #5 — table-driven wire test. Added One thing this surfaced that's worth flagging explicitly: negotiating #3 — gate on negotiated version, one shared helper. Also done, since once I'd traced through exactly how #4 — PR description correction. Updated, though the situation changed slightly from what you flagged: since the hints are now version-gated (#3), a #6 — CHANGELOG. Added an
|
Motivation and Context
A yarr server running in Streamable HTTP mode is rejected by spec-strict MCP clients before any tool can be called. Claude Code reports the transport as connected but fails to fetch tools:
MCP protocol revision
2026-07-28madettlMsandcacheScoperequired ontools/list,prompts/list,resources/list,resources/templates/listandresources/read, via the newCacheableResultinterface (SEP-2549, changelog item 5). yarr 2.2.2 pinsrmcp/rmcp-macros=3.1.0, which does not emit them.Why the known upstream fix isn't enough on its own
This is modelcontextprotocol/rust-sdk#1114, fixed in
rmcp-macros3.1.1 (PR #1120) — but that fix only covers the macro-generated path. yarr implementsServerHandlerby hand and doesn't use#[tool_handler]/#[prompt_handler], so bumping the dependency alone changes nothing. All handlers build their result with..Default::default(), which leavesttl_ms/cache_scopeasNone, and both fields areskip_serializing_if = "Option::is_none"— so they're simply absent on the wire.Why only HTTP breaks
The fields are missing on both transports, but stdio happens to survive: rmcp's stdio path emits
resultType: "complete", which lets clients apply their own default backfill for the missing cache hints, whereas the streamable-HTTP path omitsresultType, so nothing rescues them there.This also means the failure is transport-determined, not tool-mode-determined. Verified against
claude mcp list, all four combinations:YARR_MCP_TOOL_MODEflatcodemodecodemodeflatChanges
Cargo.toml:rmcp=3.1.0→=3.1.4(+ lockfile refresh), picking up the rust-sdk fixes.src/mcp/rmcp_server.rs: a localCacheableResulttrait + a singlewith_cache_hints(result, context, ttl_ms)helper attach the hints on all five cacheable results —list_tools,list_resources,list_resource_templates(yarr defines no resource templates, but the method is still callable and previously returned rmcp's defaultNone/None),read_resource, andlist_prompts. One place, instead of a call site per handler across two modules.with_cache_hintsgates on the caller's negotiated protocol version (>= 2026-07-28) — the exact condition rmcp's own#[tool_handler]/#[prompt_handler]macros use internally. A caller on an older protocol version gets the previous wire format unchanged, matching upstream's own behavior rather than emitting the fields unconditionally.cacheScopeisPrivate, not rmcp'sPublicdefault. yarr is commonly deployed inflattool mode behind a shared gateway/cache;Publicwould let an intermediary serve one caller'stools/list(service names) or schemaresources/readto a different, unauthenticated caller. None of the five handlers here vary their content per caller (each callsrequire_auth_contextfirst), so this isn't a cross-user data leak inside one process — it's specifically about what a downstream cache is permitted to do with the response.src/mcp/prompts.rsgoes back to returning plain data (list_prompts()no longer sets the hints itself) — the caching policy for it, same as everything else, lives inwith_cache_hints.LIST_RESULT_TTL_MSrenamedCACHEABLE_RESULT_TTL_MS(it also governedread_resource, not just list results).CHANGELOG.md:[Unreleased] > Fixedentry.TTL values are unchanged (5 min for config-derived results, 10 min for the static prompt list) and still easy to tune.
How Has This Been Tested?
cargo check --workspace --all-targets --locked,cargo clippy -p yarr --all-targets --locked -- -D warnings,cargo fmt --check— all clean.cargo test -p yarr --locked— 603 passing. The single-methodlist_prompts_carries_sep_2549_cache_hintsunit test is replaced by a wire-level, table-driven test (src/server/routes_tests/cache_hints_tests.rs) that POSTs raw JSON-RPC through the real router/transport (no mockedRequestContext) for all five cacheable methods, in two scenarios:2026-07-28(via theMCP-Protocol-Versionheader, plus the_meta/SEP-2243 header plumbing that version requires independent of this fix) gets the exact expectedttlMsper method andcacheScope: "private".2025-03-26) gets neither field, on all five methods.config/Dockerfileand ran it end-to-end against Claude Code's real MCP client:! Connected · tools fetch failed(error above).✔ Connected;tools/listreturns"ttlMs": 300000, "cacheScope": "private"with all 8 configured services; a realtools/call(sonarr/service_status) returns live Sonarr data;resources/templates/listreturns"ttlMs": 300000, "cacheScope": "private", "resourceTemplates": []instead of the earlier bare rejection-triggering response.Breaking Changes
None.
cacheScope: "private"only restricts sharing at intermediary caches — every client still caches its own copy. Clients negotiating an older protocol version are unaffected: they now receive the exact previous wire format (nottlMs/cacheScopeat all), since the hints are version-gated rather than emitted unconditionally.Types of changes
Checklist
Update: addressed @jmagar's review —
resources/templates/listcoverage (blocking),cacheScope: private, version-gated hints via a single shared helper, the wire-level test across all five methods, and the CHANGELOG entry. Reply below with specifics on each point.