Merge pull request #499 from koic/stamp_cache_hints_on_modern_results
Stamp the required cache hints on modern cacheable results per SEP-2549
This commit is contained in:
commit
b09708365b
3 changed files with 87 additions and 8 deletions
|
|
@ -2380,8 +2380,11 @@ Per SEP-2549, list and read results can carry cache hints telling clients how lo
|
|||
`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`).
|
||||
|
||||
Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`,
|
||||
`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "public"`).
|
||||
`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "private"`,
|
||||
the scope that keeps a potentially user-dependent result out of shared caches).
|
||||
When neither is set, responses are serialized exactly as before.
|
||||
The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern `_meta` envelope
|
||||
the server always emits them, filling unset values with the same defaults; stable protocol versions keep the opt-in behavior.
|
||||
|
||||
```ruby
|
||||
server = MCP::Server.new(
|
||||
|
|
|
|||
|
|
@ -145,6 +145,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, :request_state_security
|
||||
|
||||
|
|
@ -656,6 +668,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)
|
||||
|
|
@ -1182,15 +1206,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)
|
||||
|
|
|
|||
|
|
@ -3960,15 +3960,16 @@ module MCP
|
|||
end
|
||||
|
||||
test "list results carry ttlMs and cacheScope when ttl_ms is configured" do
|
||||
# SEP-2549 cache hints. The cacheScope default is "public", matching
|
||||
# the spec default and the Python SDK.
|
||||
# SEP-2549 cache hints. The unset cacheScope fills as "private", the side that cannot
|
||||
# leak a user-dependent result through a shared cache, matching the TypeScript and
|
||||
# Python SDK defaults (the spec names no default scope).
|
||||
server = Server.new(name: "ttl_test", ttl_ms: 5000)
|
||||
|
||||
["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index|
|
||||
result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result]
|
||||
|
||||
assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs"
|
||||
assert_equal "public", result[:cacheScope], "#{method} missing cacheScope"
|
||||
assert_equal "private", result[:cacheScope], "#{method} missing cacheScope"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -4029,7 +4030,7 @@ module MCP
|
|||
})[:result]
|
||||
|
||||
assert_equal 60_000, result[:ttlMs]
|
||||
assert_equal "public", result[:cacheScope]
|
||||
assert_equal "private", result[:cacheScope]
|
||||
assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents]
|
||||
end
|
||||
|
||||
|
|
@ -4311,6 +4312,56 @@ module MCP
|
|||
assert_equal "complete", response.dig(:result, :resultType)
|
||||
end
|
||||
|
||||
# SEP-2549 makes the `ttlMs`/`cacheScope` cache hints REQUIRED on cacheable results at 2026-07-28;
|
||||
# unset hints get the spec defaults, and stable protocol versions keep the opt-in emission.
|
||||
test "modern cacheable results carry the default cache hints" do
|
||||
server = Server.new(name: "cache_hints_test", tools: [result_type_tool])
|
||||
|
||||
response = server.handle(modern_request(Methods::TOOLS_LIST, {}))
|
||||
|
||||
assert_equal 0, response.dig(:result, :ttlMs)
|
||||
assert_equal "private", response.dig(:result, :cacheScope)
|
||||
end
|
||||
|
||||
test "modern non-cacheable results carry no cache hints" do
|
||||
server = Server.new(name: "cache_hints_test", tools: [result_type_tool])
|
||||
|
||||
response = server.handle(modern_request(Methods::PING, {}))
|
||||
|
||||
refute response[:result].key?(:ttlMs)
|
||||
refute response[:result].key?(:cacheScope)
|
||||
end
|
||||
|
||||
test "configured cache hints win over the modern defaults" do
|
||||
server = Server.new(name: "cache_hints_test", tools: [result_type_tool], ttl_ms: 5000, cache_scope: "private")
|
||||
|
||||
response = server.handle(modern_request(Methods::TOOLS_LIST, {}))
|
||||
|
||||
assert_equal 5000, response.dig(:result, :ttlMs)
|
||||
assert_equal "private", response.dig(:result, :cacheScope)
|
||||
end
|
||||
|
||||
test "modern resources/read results carry the default cache hints" do
|
||||
server = Server.new(name: "cache_hints_test")
|
||||
server.resources_read_handler do |params|
|
||||
[{ uri: params[:uri], mimeType: "text/plain", text: "hi" }]
|
||||
end
|
||||
|
||||
response = server.handle(modern_request(Methods::RESOURCES_READ, { uri: "file:///x" }))
|
||||
|
||||
assert_equal 0, response.dig(:result, :ttlMs)
|
||||
assert_equal "private", response.dig(:result, :cacheScope)
|
||||
end
|
||||
|
||||
test "legacy cacheable results keep the opt-in cache hint emission" do
|
||||
server = Server.new(name: "cache_hints_test", tools: [result_type_tool])
|
||||
|
||||
response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 })
|
||||
|
||||
refute response[:result].key?(:ttlMs)
|
||||
refute response[:result].key?(:cacheScope)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds a request carrying the SEP-2575 modern `_meta` envelope.
|
||||
|
|
|
|||
Loading…
Reference in a new issue