Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 28 additions & 3 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 55 additions & 4 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down