Stamp the required cache hints on modern cacheable results per SEP-2549
## 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.
This commit is contained in:
parent
4fac357343
commit
5864b8431c
1 changed files with 87 additions and 8 deletions
|
|
@ -144,6 +144,18 @@ module MCP
|
|||
# Allowed values for the SEP-2549 `cacheScope` cache hint.
|
||||
CACHE_SCOPES = ["public", "private"].freeze
|
||||
|
||||
# Methods whose results are cacheable per SEP-2549.
|
||||
# On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results,
|
||||
# so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in
|
||||
# via `apply_cache_metadata`.
|
||||
CACHEABLE_RESULT_METHODS = [
|
||||
Methods::TOOLS_LIST,
|
||||
Methods::PROMPTS_LIST,
|
||||
Methods::RESOURCES_LIST,
|
||||
Methods::RESOURCES_TEMPLATES_LIST,
|
||||
Methods::RESOURCES_READ,
|
||||
].freeze
|
||||
|
||||
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
||||
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
|
||||
|
||||
|
|
@ -639,6 +651,18 @@ module MCP
|
|||
result = result.merge(resultType: ResultType::COMPLETE)
|
||||
end
|
||||
|
||||
# SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28,
|
||||
# so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`:
|
||||
# the spec names no default scope, and `"private"` is the side that cannot leak
|
||||
# a user-dependent result through a shared cache, matching the TypeScript SDK's default
|
||||
# and `server/discover`. Values already in the result win.
|
||||
# Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped
|
||||
# (the stamp above guarantees `resultType` is present on every modern Hash result by this point).
|
||||
if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) &&
|
||||
result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope))
|
||||
result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
|
||||
end
|
||||
|
||||
result
|
||||
rescue CancelledError => e
|
||||
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
||||
|
|
@ -1082,15 +1106,16 @@ module MCP
|
|||
end
|
||||
|
||||
# Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
|
||||
# unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
|
||||
# which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
|
||||
# unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields,
|
||||
# in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`,
|
||||
# the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default).
|
||||
# Values already in the result win, enabling per-result overrides.
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
|
||||
def apply_cache_metadata(result)
|
||||
explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
|
||||
return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
|
||||
|
||||
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
|
||||
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
|
||||
end
|
||||
|
||||
def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||
|
|
|
|||
Loading…
Reference in a new issue