diff --git a/README.md b/README.md index f83cdaa5..17e3d14e 100644 --- a/README.md +++ b/README.md @@ -2346,8 +2346,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( diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 262352f6..08dce622 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -144,6 +144,18 @@ class ValidationError < StandardError; end # 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 @@ def handle_request(request, method, session: nil, related_request_id: nil) 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 @@ def build_read_resource_result(handler_result) 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) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 69d9b8cb..634b8c6c 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3856,15 +3856,16 @@ def server_context 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 @@ -3925,7 +3926,7 @@ def server_context })[: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 @@ -4207,6 +4208,56 @@ def server_context 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.