Skip to content

fix(mcp): emit SEP-2549 cache hints so HTTP clients accept list results - #129

Open
ThomasDalla wants to merge 2 commits into
dinglebear-ai:mainfrom
ThomasDalla:fix/sep-2549-cache-hints-http
Open

ThomasDalla wants to merge 2 commits into
dinglebear-ai:mainfrom
ThomasDalla:fix/sep-2549-cache-hints-http

Conversation

@ThomasDalla

@ThomasDalla ThomasDalla commented Aug 30, 2026

Copy link
Copy Markdown

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:

Invalid result for tools/list: [
  { "expected": "number", "code": "invalid_type", "path": ["ttlMs"], "message": "Invalid input: expected number, received undefined" },
  { "code": "invalid_value", "values": ["public","private"], "path": ["cacheScope"], "message": "Invalid option: expected one of \"public\"|\"private\"" }
]

MCP protocol revision 2026-07-28 made ttlMs and cacheScope required on tools/list, prompts/list, resources/list, resources/templates/list and resources/read, via the new CacheableResult interface (SEP-2549, changelog item 5). yarr 2.2.2 pins rmcp/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-macros 3.1.1 (PR #1120) — but that fix only covers the macro-generated path. yarr implements ServerHandler by 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 leaves ttl_ms/cache_scope as None, and both fields are skip_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 omits resultType, 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:

Transport YARR_MCP_TOOL_MODE Result
HTTP flat ✗ tools fetch failed
HTTP codemode ✗ tools fetch failed
stdio codemode ✓ connects
stdio flat ✓ connects

Changes

  1. Cargo.toml: rmcp =3.1.0=3.1.4 (+ lockfile refresh), picking up the rust-sdk fixes.
  2. src/mcp/rmcp_server.rs: a local CacheableResult trait + a single with_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 default None/None), read_resource, and list_prompts. One place, instead of a call site per handler across two modules.
  3. with_cache_hints gates 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.
  4. cacheScope is Private, not rmcp's Public default. yarr is commonly deployed in flat tool mode behind a shared gateway/cache; Public would let an intermediary serve one caller's tools/list (service names) or schema resources/read to a different, unauthenticated caller. None of the five handlers here vary their content per caller (each calls require_auth_context first), 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.
  5. src/mcp/prompts.rs goes back to returning plain data (list_prompts() no longer sets the hints itself) — the caching policy for it, same as everything else, lives in with_cache_hints.
  6. LIST_RESULT_TTL_MS renamed CACHEABLE_RESULT_TTL_MS (it also governed read_resource, not just list results).
  7. CHANGELOG.md: [Unreleased] > Fixed entry.

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-method list_prompts_carries_sep_2549_cache_hints unit 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 mocked RequestContext) for all five cacheable methods, in two scenarios:
    • A caller negotiating 2026-07-28 (via the MCP-Protocol-Version header, plus the _meta/SEP-2243 header plumbing that version requires independent of this fix) gets the exact expected ttlMs per method and cacheScope: "private".
    • A caller that never negotiates it (no header — rmcp's stateless default is 2025-03-26) gets neither field, on all five methods.
  • Built the image from config/Dockerfile and ran it end-to-end against Claude Code's real MCP client:
    • Before: ! Connected · tools fetch failed (error above).
    • After: ✔ Connected; tools/list returns "ttlMs": 300000, "cacheScope": "private" with all 8 configured services; a real tools/call (sonarr / service_status) returns live Sonarr data; resources/templates/list returns "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 (no ttlMs/cacheScope at all), since the hints are version-gated rather than emitted unconditionally.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated tests as appropriate

Update: addressed @jmagar's review — resources/templates/list coverage (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.

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.
@ThomasDalla
ThomasDalla requested a review from jmagar as a code owner August 30, 2026 20:19
@jmagar

jmagar commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

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 ServerHandler silently opts out of the rmcp-macros fix, and that stdio survives only because rmcp emits resultType there and clients backfill.

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

  • cargo clippy --all-targets --locked -- -D warnings: clean.
  • cargo test --locked: passing (602 + 59).
  • I drove all five cacheable methods over the real axum + streamable-HTTP stack, in both codemode and flat modes, including at a negotiated 2026-07-28. Four methods carry ttlMs/cacheScope after your change. One does not — see chore(deps-dev): bump @types/react from 19.2.14 to 19.2.15 in /apps/web in the react group #1.
  • The Cargo.lock delta is fully explained by the bump (darling 0.23→0.24, a second syn major, indexmap, mechanical renames). Nothing unrelated, and both new MSRVs are below our 1.97.1.

1. resources/templates/list is still uncovered (the one blocker)

get_info advertises resources via .enable_resources() (src/mcp/rmcp_server.rs:253-259), but YarrRmcpServer never overrides list_resource_templates, so rmcp's default runs (rmcp-3.1.4/src/handler/server.rs:387-395) and returns ListResourceTemplatesResult::default() — both hints None.

On the wire, after your change:

tools/list              → "ttlMs":300000,"cacheScope":"public"
resources/list          → "ttlMs":300000,"cacheScope":"public"
resources/read          → "ttlMs":300000,"cacheScope":"public"
prompts/list            → "ttlMs":600000,"cacheScope":"public"
resources/templates/list→ {"resultType":"complete","resourceTemplates":[]}   ← bare

A strict 2026-07-28 client that calls it still gets the rejection this PR exists to remove. Overriding it to return an empty list with the same treatment closes the last hole. I checked the rest: no other List*Result or ReadResourceResult construction exists outside generated code, and GetPromptResult/CallToolResult carry no cache fields in 3.1.4, so this is the only one left.

2. cacheScope — I would like private, and I want to be upfront that this is a divergence from upstream

All four handlers call require_auth_context first, and the content does not vary per caller (rmcp_tool_definitions_for_service depends only on tool_mode and configured services). So this is not a cross-user data leak inside one process.

The concern is intermediaries. SEP-2549 defines public as cacheable and servable to any user, and in flat mode tools/list enumerates the operator's configured service names while the schema resource carries the full tool schema. yarr is explicitly deployed behind gateways (that is what YARR_MCP_TOOL_MODE=flat exists for), and a shared cache there could serve that to a requester who never passed our auth check.

In fairness: rmcp's own macros emit Public too, so private is us deliberately diverging, not you following the wrong example. Each client still caches its own copy, so the cost is near zero. Happy to hear the counter-argument if you think public is right.

3. Gate on the negotiated version, and put the policy in one place (optional but recommended)

rmcp's macros only attach the hints when the negotiated version is >= V_2026_07_28, and they use ttl 0 (rmcp-macros-3.1.4/src/tool_handler.rs:66-82, prompt_handler.rs:56-78). This PR attaches them unconditionally. That is harmless today (see #4), but the policy currently lives at four call sites across two modules with two different TTLs (LIST_RESULT_TTL_MS = 5 min in rmcp_server.rs:42, PROMPTS_LIST_TTL_MS = 10 min in prompts.rs:18), and prompts.rs is otherwise a pure data module.

One small helper in rmcp_server.rs — a local trait over the five result types plus a single with_cache_hints(result, ctx) — would make the next cacheable handler impossible to forget, let prompts::list_prompts() go back to returning plain data, and put the version check in one place. The accessor is RequestContext::<RoleServer>::protocol_version() (rmcp-3.1.4/src/service.rs:1223); note ctx.peer.peer_info() is documented as the legacy-session fallback only — I got that wrong at first myself.

Minor: LIST_RESULT_TTL_MS also governs read_resource, which is not a list, so a name like CACHEABLE_RESULT_TTL_MS would read better.

4. One correction to the PR description (no code change needed)

The description says clients negotiating 2025-06-18 "still receive the previous wire format". They actually receive ttlMs/cacheScope as well — rmcp's strip_result_type_for_legacy_peer (model.rs:4596-4609) removes only resultType. I confirmed this on the wire at 2025-06-18. It is harmless, because unknown result fields are ignored, but it would be good for the description to say "additive fields, ignored by older clients" instead.

5. Tests — the scaffolding you wanted already exists

Completely understandable that you did not find it, and thank you for being upfront about the gap. authenticated_mcp_call + counting_state (src/server/routes_tests.rs:10-102) POST raw JSON-RPC through the real router and transport, and no initialize handshake is needed because the transport is stateless (src/mcp/transport.rs:24-28, with_legacy_session_mode(false)). Existing tests already use it for tools/call (src/server/routes_tests/auth_tests.rs:14-95).

One table-driven test covers all five methods and, unlike a struct-level assertion, pins the actual wire key names so a future serde rename cannot slip through:

#[tokio::test]
async fn list_and_read_results_carry_sep_2549_cache_hints_on_the_wire() {
    let (state, _calls, server) = counting_state(crate::config::ToolMode::Codemode).await;
    for (id, method, params) in [
        (10, "tools/list", json!({})),
        (11, "resources/list", json!({})),
        (12, "resources/templates/list", json!({})),
        (13, "resources/read", json!({"uri": "yarr://schema/mcp-tool"})),
        (14, "prompts/list", json!({})),
    ] {
        let r = authenticated_mcp_call(state.clone(), "read-token",
            json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
        assert!(r["result"]["ttlMs"].is_u64(), "{method} missing ttlMs: {r}");
        assert!(r["result"]["cacheScope"].is_string(), "{method} missing cacheScope: {r}");
    }
    server.abort();
}

Worth running the same loop for ToolMode::Flat. Your existing list_prompts_carries_sep_2549_cache_hints is fine, though it currently asserts the constant against itself, so it would not catch a rename.

6. CHANGELOG, and how to describe the bump

Our convention needs an entry under [Unreleased] > Fixed. On the bump itself: rmcp 3.1.0 already has with_ttl_ms/with_cache_scope on all four list types and ReadResourceResult, so it is not strictly required for this fix — but I would still take it. One caveat for the wording: the auth hardening in 3.1.1–3.1.4 (#1166, #1167, #1177) lives in rmcp::transport::auth, a client-side OAuth module we never enable, so it should not be credited as an auth fix for yarr. There is one server-side change that reaches us: #1160 means malformed _meta at 2026-07-28 now returns -32602 instead of being dropped.

Summary

Blocking: #1. Wanted before merge: #2 and #5. Nice to have: #3, #4, #6.

Two housekeeping notes, neither yours to fix: CI on fork PRs needs a maintainer to approve the workflow run, which I will do. And this exposed a real gap on our side — yarr never overrides supported_protocol_versions, so it inherits ProtocolVersion::KNOWN_VERSIONS and every rmcp bump silently opts us into new spec revisions with new required fields. That is the root cause of this whole class of bug, and I have filed it separately. Thanks again for finding and fixing this properly rather than working around it.

…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>
@ThomasDalla

Copy link
Copy Markdown
Author

Thanks for the thorough review — all of it made the fix better. Pushed a follow-up commit (5cf104d) addressing every point:

#1 (blocking) — resources/templates/list. Fixed: YarrRmcpServer now overrides list_resource_templates, calling require_auth_context first like the other four handlers and returning ListResourceTemplatesResult::default() through the same with_cache_hints helper the rest use. Confirmed enable_resources() is the only capability flag involved (no separate templates sub-flag in ServerCapabilities), so no get_info change was needed.

#2cacheScope: private. Done, and I agree with your reasoning — happy to take the divergence from rmcp's Public default given flat mode's gateway-facing deployment shape.

#5 — table-driven wire test. Added src/server/routes_tests/cache_hints_tests.rs, extending authenticated_mcp_call/counting_state per your pointer (added a thin authenticated_mcp_call_with_headers sibling so I could set MCP-Protocol-Version per call without touching the existing helper's signature/callers). It covers all five methods in two scenarios: a 2026-07-28 caller (asserting the exact ttlMs per method, not just presence, and cacheScope == "private") and a caller that never negotiates that version (asserting both fields are absent). The old list_prompts_carries_sep_2549_cache_hints unit test is removed — its coverage is subsumed by the wire test, and prompts::list_prompts() is back to being plain data (see #3).

One thing this surfaced that's worth flagging explicitly: negotiating 2026-07-28 on the real transport also requires SEP-2243's Mcp-Method/Mcp-Name HTTP headers and SEP-2575's _meta["io.modelcontextprotocol/protocolVersion"]/_meta["io.modelcontextprotocol/clientCapabilities"] in the request body — both enforced by rmcp's transport layer before a handler ever runs, unconditionally (not gated by stateless_protocol_metadata_required). Neither is new to this PR, but getting the "2026-07-28 caller" test case to actually reach YarrRmcpServer meant reproducing both, which is documented in the test file.

#3 — gate on negotiated version, one shared helper. Also done, since once I'd traced through exactly how RequestContext::protocol_version() resolves on yarr's stateless transport (peer_info_for_stateless_request reconstructing it from the MCP-Protocol-Version header, confirmed against stateless_protocol_metadata_required being left at its default false in src/mcp/transport.rs) it was cheap to get right and matches your rationale for wanting it. with_cache_hints<T: CacheableResult>(result, context, ttl_ms) in rmcp_server.rs is the single call site; CacheableResult is implemented for all five result types via a small macro. Also renamed LIST_RESULT_TTL_MSCACHEABLE_RESULT_TTL_MS per your note that it governs read_resource too.

#4 — PR description correction. Updated, though the situation changed slightly from what you flagged: since the hints are now version-gated (#3), a 2025-06-18 (or otherwise pre-2026-07-28) caller genuinely gets the unmodified previous wire format now, rather than receiving-but-ignoring the new fields. Description now says that directly instead of the "additive, ignored by older clients" framing, which was correct for the original unconditional-emit version but not for this one.

#6 — CHANGELOG. Added an [Unreleased] > Fixed entry.

cargo fmt --check / cargo clippy --all-targets --locked -- -D warnings / cargo test --locked all clean, 603 tests passing (602 − the removed prompts unit test + 2 new wire tests).

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.

2 participants