Skip to content

Stamp the required cache hints on modern cacheable results per SEP-2549 - #499

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:stamp_cache_hints_on_modern_results
Aug 13, 2026
Merged

Stamp the required cache hints on modern cacheable results per SEP-2549#499
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:stamp_cache_hints_on_modern_results

Conversation

@koic

@koic koic commented Aug 8, 2026

Copy link
Copy Markdown
Member

Motivation and Context

The 2026-07-28 spec revision makes the SEP-2549 ttlMs / cacheScope cache hints REQUIRED members of the cacheable results (tools/list, prompts/list, resources/list, resources/templates/list, and resources/read). The SDK's existing emission is opt-in (Server.new(ttl_ms:, cache_scope:)), so servers that never opted in violate the 2026-07-28 wire schema, and the conformance suite fails those scenarios on wire-schema validation (ListToolsResult: must have required property 'cacheScope' / 'ttlMs') plus all eight checks of the caching scenario.

The dispatch path now fills the hints centrally for results of requests carrying the SEP-2575 _meta envelope, next to the SEP-2322 resultType stamp: a cacheable result that does not already carry both fields gets the configured ttl_ms / cache_scope values, with ttlMs: 0 (do not cache) and cacheScope: "private" filling anything unset. Values already in the result win, preserving per-result overrides. Only complete results are stamped: an SEP-2322 input_required round trip of resources/read is not a cacheable final result and stays hint-free.

The unset-scope fill is "private" because the spec names no default scope and "private" is the side that cannot leak a user-dependent resources/read result through a shared cache; it is also the unconfigured default of the TypeScript SDK (cacheScope: 'private'), the Python SDK (CacheHint.scope), and this SDK's own server/discover. The opt-in emission of apply_cache_metadata used to fill a missing scope as "public" when only ttl_ms was configured and now fills "private" for the same reason, so both paths agree. Stable protocol versions otherwise keep the opt-in emission unchanged.

How Has This Been Tested?

bundle exec rake test passes with zero failures. New tests in test/mcp/server_test.rb: modern tools/list and resources/read results carry the default hints, configured values win over the defaults, non-cacheable modern results (ping) stay untouched, and legacy cacheable results keep the opt-in emission. The opt-in emission tests assert the "private" fill for a configured ttl_ms without a scope. The previously failing conformance scenarios (tools-list, resources-read-text, and caching) pass at --spec-version 2026-07-28 against the conformance fixture server, and the 2025-11-25 leg is unchanged.

Breaking Changes

The modern-path emission is additive: it applies only to results of requests carrying the SEP-2575
modern _meta envelope. One deliberate change reaches stable versions: a server configured with
ttl_ms: but no cache_scope: now emits cacheScope: "private" instead of "public" on its opt-in results;
declaring cache_scope: "public" explicitly restores the shared-cacheable scope.

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 documentation as needed

## Motivation and Context

The 2026-07-28 spec revision makes the SEP-2549 `ttlMs` / `cacheScope` cache hints REQUIRED members of
the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`,
and `resources/read`). The SDK's existing emission is opt-in (`Server.new(ttl_ms:, cache_scope:)`),
so servers that never opted in violate the 2026-07-28 wire schema, and the conformance suite fails
those scenarios on wire-schema validation (`ListToolsResult: must have required property 'cacheScope' / 'ttlMs'`)
plus all eight checks of the `caching` scenario.

The dispatch path now fills the hints centrally for results of requests carrying the SEP-2575 `_meta` envelope,
next to the SEP-2322 `resultType` stamp: a cacheable result that does not already carry both fields gets
the configured `ttl_ms` / `cache_scope` values, with `ttlMs: 0` (do not cache) and `cacheScope: "private"`
filling anything unset. Values already in the result win, preserving per-result overrides.
Only complete results are stamped: an SEP-2322 `input_required` round trip of `resources/read` is not
a cacheable final result and stays hint-free.

The unset-scope fill is `"private"` because the spec names no default scope and `"private"` is the side
that cannot leak a user-dependent `resources/read` result through a shared cache; it is also
the unconfigured default of the TypeScript SDK (`cacheScope: 'private'`), the Python SDK (`CacheHint.scope`),
and this SDK's own `server/discover`. The opt-in emission of `apply_cache_metadata` used to fill a missing
scope as `"public"` when only `ttl_ms` was configured and now fills `"private"` for the same reason,
so both paths agree. Stable protocol versions otherwise keep the opt-in emission unchanged.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: modern `tools/list`
and `resources/read` results carry the default hints, configured values win over the defaults,
non-cacheable modern results (`ping`) stay untouched, and legacy cacheable results keep the opt-in emission.
The opt-in emission tests assert the `"private"` fill for a configured `ttl_ms` without a scope.
The previously failing conformance scenarios (`tools-list`, `resources-read-text`, and `caching`) pass at
`--spec-version 2026-07-28` against the conformance fixture server, and the 2025-11-25 leg is unchanged.

## Breaking Changes

The modern-path emission is additive: it applies only to results of requests carrying the SEP-2575
modern `_meta` envelope. One deliberate change reaches stable versions: a server configured with
`ttl_ms:` but no `cache_scope:` now emits `cacheScope: "private"` instead of `"public"` on its opt-in results;
declaring `cache_scope: "public"` explicitly restores the shared-cacheable scope.
@koic koic changed the title Stamp the Required Cache Hints on Modern Cacheable Results per SEP-2549 Stamp the required cache hints on modern cacheable results per SEP-2549 Aug 8, 2026
@koic
koic force-pushed the stamp_cache_hints_on_modern_results branch from cfbda93 to 5864b84 Compare August 8, 2026 16:25
@koic
koic merged commit b097083 into modelcontextprotocol:main Aug 13, 2026
11 checks passed
@koic
koic deleted the stamp_cache_hints_on_modern_results branch August 13, 2026 14:14
koic added a commit that referenced this pull request Aug 13, 2026
## Motivation and Context

`main` is red on every Ruby version: "modern non-cacheable results carry no cache hints" errors with
`undefined method 'key?' for nil`. The test reaches for `response[:result]` on a request that no longer
produces a result.

Two changes met on `main` without conflicting textually. #499 added the test and picked `ping` as its example of
a result that is not cacheable. #511 then made a modern-enveloped request naming a method SEP-2575 removed answer
with `-32601` even before the connection era locks, and `ping` is one of those methods, so the response now carries
an error rather than a result. Each branch was green against the `main` it was written on; the pairing only failed
once both had landed.

`ping` was the wrong example regardless. It is absent from the 2026-07-28 schema, so no modern request can name it
and receive a result of any shape. `tools/call` is the accurate stand-in: the spec types `CallToolResult` as
a plain `Result`, while the ones it types as `CacheableResult` are the discover, list, and read families.
The test now calls the tool it already registers, and a comment records why `ping` cannot come back here -
the same note #511 left where it replaced `ping` with `prompts/list` in the neighboring `resultType` test.

Only the test changes. The behavior both #499 and #511 implement is correct; the example the test chose was not.

## How Has This Been Tested?

The test errors on `main` and passes with this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
The rest of the suite was searched for the same pattern, a modern-enveloped request naming a method the modern lifecycle removed:
the only other occurrence is the assertion #511 added deliberately, which checks that such a request does not lock the era,
and which reads no result.

## Breaking Changes

None. No library code changes.
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.

1 participant