Add Opt-In ttlMs / cacheScope Cache Hints to List and Read Results per SEP-2549
## Motivation and Context SEP-2549 (modelcontextprotocol/modelcontextprotocol#2549, merged for the 2026-07-28 spec release) adds a `CacheableResult` shape with `ttlMs` (freshness lifetime in milliseconds, max-age semantics, 0 = do not cache) and `cacheScope` (`"public"` or `"private"`, whether shared intermediaries may cache) to `ListToolsResult`, `ListPromptsResult`, `ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult`, complementing the `listChanged` notifications. The field shape follows the Python SDK's implementation (python-sdk#2824, `ttlMs` + `cacheScope` with `ttlMs: 0` / `cacheScope: "public"` defaults; the TypeScript SDK's earlier `ttl`-seconds prototype #2070 was superseded by this shape). Because this SDK negotiates protocol versions up to 2025-11-25, where the fields do not exist, emission is opt-in: when neither `ttl_ms` nor `cache_scope` is configured, responses are serialized exactly as before. The fields become REQUIRED in 2026-07-28; a follow-up can auto-emit defaults once that version enters `SUPPORTED_STABLE_PROTOCOL_VERSIONS`. - `MCP::Server.new` gains `ttl_ms:` and `cache_scope:` keywords with validating writers modeled on `page_size=` (`ttl_ms` must be nil or a non-negative Integer; `cache_scope` must be nil, `"public"`, or `"private"`). - A private `apply_cache_metadata` wraps the five emission points. When the user sets only one of the two values, the other is filled with the spec defaults (`ttlMs: 0`, the only universally safe freshness value, and `cacheScope: "public"`, the spec and Python default). - A `resources_read_handler` may return a full `{ contents:, ttlMs:, cacheScope: }` hash to override the server-level hints per result; the documented bare-contents return shape keeps working unchanged. - The client's `ListToolsResult` / `ListPromptsResult` / `ListResourcesResult` / `ListResourceTemplatesResult` structs gain `ttl_ms` and `cache_scope` members populated from the response fields. Resolves #387. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb`: - with `ttl_ms: 5000`, all four list results carry `ttlMs: 5000` and the filled `cacheScope: "public"` default - with only `cache_scope: "private"`, `resources/read` carries `ttlMs: 0` - with neither configured, list and read results omit both fields (wire-format regression) - the hints appear alongside `nextCursor` when paginating - a `resources_read_handler` returning `{ contents:, ttlMs: 60_000 }` overrides the server-level value while the missing `cacheScope` is filled - invalid writer values (`-1`, `1.5`, `"internal"`) raise `ArgumentError` New tests in `test/mcp/client_test.rb` assert all four list result structs expose `ttl_ms` / `cache_scope`, and that both are nil when the server omits the fields. ## Breaking Changes None. Emission is opt-in, the new constructor keywords default to nil, and the client structs gain additive members.
This commit is contained in:
parent
49d6d7f254
commit
3339ceb8c1
6 changed files with 227 additions and 12 deletions
34
README.md
34
README.md
|
|
@ -1850,6 +1850,40 @@ tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
|
|||
Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
|
||||
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
|
||||
|
||||
#### List Result Caching (`ttlMs` / `cacheScope`)
|
||||
|
||||
Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds;
|
||||
`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"`).
|
||||
When neither is set, responses are serialized exactly as before.
|
||||
|
||||
```ruby
|
||||
server = MCP::Server.new(
|
||||
name: "my_server",
|
||||
tools: tools,
|
||||
ttl_ms: 60_000, # results stay fresh for one minute
|
||||
cache_scope: "private", # only the requesting client may cache them
|
||||
)
|
||||
```
|
||||
|
||||
A `resources_read_handler` can override the hints per result by returning a full result hash instead of bare contents:
|
||||
|
||||
```ruby
|
||||
server.resources_read_handler do |params|
|
||||
{ contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 }
|
||||
end
|
||||
```
|
||||
|
||||
On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`:
|
||||
|
||||
```ruby
|
||||
page = client.list_tools
|
||||
page.ttl_ms # => 60000 (nil when the server sent no hint)
|
||||
page.cache_scope # => "private"
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
#### Custom Methods
|
||||
|
|
|
|||
|
|
@ -134,7 +134,13 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
|
||||
ListToolsResult.new(
|
||||
tools: tools,
|
||||
next_cursor: result["nextCursor"],
|
||||
meta: result["_meta"],
|
||||
ttl_ms: result["ttlMs"],
|
||||
cache_scope: result["cacheScope"],
|
||||
)
|
||||
end
|
||||
|
||||
# Returns every tool available on the server. Iterates through all pages automatically
|
||||
|
|
@ -175,6 +181,8 @@ module MCP
|
|||
resources: result["resources"] || [],
|
||||
next_cursor: result["nextCursor"],
|
||||
meta: result["_meta"],
|
||||
ttl_ms: result["ttlMs"],
|
||||
cache_scope: result["cacheScope"],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -208,6 +216,8 @@ module MCP
|
|||
resource_templates: result["resourceTemplates"] || [],
|
||||
next_cursor: result["nextCursor"],
|
||||
meta: result["_meta"],
|
||||
ttl_ms: result["ttlMs"],
|
||||
cache_scope: result["cacheScope"],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -241,6 +251,8 @@ module MCP
|
|||
prompts: result["prompts"] || [],
|
||||
next_cursor: result["nextCursor"],
|
||||
meta: result["_meta"],
|
||||
ttl_ms: result["ttlMs"],
|
||||
cache_scope: result["cacheScope"],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ module MCP
|
|||
class Client
|
||||
# Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
||||
# Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
|
||||
# and an optional `meta` hash mirroring the MCP `_meta` response field.
|
||||
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
|
||||
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
|
||||
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
|
||||
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
|
||||
# an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549
|
||||
# cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and
|
||||
# `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields.
|
||||
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
||||
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
||||
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
||||
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -102,8 +102,11 @@ module MCP
|
|||
include Instrumentation
|
||||
include Pagination
|
||||
|
||||
# Allowed values for the SEP-2549 `cacheScope` cache hint.
|
||||
CACHE_SCOPES = ["public", "private"].freeze
|
||||
|
||||
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
||||
attr_reader :page_size, :client_capabilities
|
||||
attr_reader :page_size, :client_capabilities, :ttl_ms, :cache_scope
|
||||
|
||||
def initialize(
|
||||
description: nil,
|
||||
|
|
@ -121,6 +124,8 @@ module MCP
|
|||
configuration: nil,
|
||||
capabilities: nil,
|
||||
page_size: nil,
|
||||
ttl_ms: nil,
|
||||
cache_scope: nil,
|
||||
transport: nil
|
||||
)
|
||||
@description = description
|
||||
|
|
@ -138,6 +143,8 @@ module MCP
|
|||
@resource_index = index_resources_by_uri(resources)
|
||||
@server_context = server_context
|
||||
self.page_size = page_size
|
||||
self.ttl_ms = ttl_ms
|
||||
self.cache_scope = cache_scope
|
||||
@configuration = MCP.configuration.merge(configuration)
|
||||
@client = nil
|
||||
|
||||
|
|
@ -232,6 +239,27 @@ module MCP
|
|||
@page_size = page_size
|
||||
end
|
||||
|
||||
# SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results
|
||||
# (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms`
|
||||
# and `cache_scope` are nil, results are serialized exactly as before.
|
||||
def ttl_ms=(ttl_ms)
|
||||
unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0)
|
||||
raise ArgumentError, "ttl_ms must be nil or a non-negative integer"
|
||||
end
|
||||
|
||||
@ttl_ms = ttl_ms
|
||||
end
|
||||
|
||||
# SEP-2549 cache hint: whether shared intermediaries may cache the result ("public")
|
||||
# or only the requesting client ("private").
|
||||
def cache_scope=(cache_scope)
|
||||
unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope)
|
||||
raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\""
|
||||
end
|
||||
|
||||
@cache_scope = cache_scope
|
||||
end
|
||||
|
||||
def notify_tools_list_changed
|
||||
return unless @transport
|
||||
|
||||
|
|
@ -472,7 +500,7 @@ module MCP
|
|||
when Methods::INITIALIZE
|
||||
init(params, session: session)
|
||||
when Methods::RESOURCES_READ
|
||||
{ contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
|
||||
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
|
||||
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
||||
{}
|
||||
|
|
@ -611,7 +639,7 @@ module MCP
|
|||
def list_tools(request)
|
||||
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
||||
|
||||
{ tools: page[:items], nextCursor: page[:next_cursor] }.compact
|
||||
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||
end
|
||||
|
||||
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
||||
|
|
@ -667,7 +695,7 @@ module MCP
|
|||
def list_prompts(request)
|
||||
page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
||||
|
||||
{ prompts: page[:items], nextCursor: page[:next_cursor] }.compact
|
||||
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||
end
|
||||
|
||||
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
||||
|
|
@ -696,7 +724,7 @@ module MCP
|
|||
def list_resources(request)
|
||||
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
||||
|
||||
{ resources: page[:items], nextCursor: page[:next_cursor] }.compact
|
||||
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||
end
|
||||
|
||||
# Server implementation should set `resources_read_handler` to override no-op default
|
||||
|
|
@ -708,7 +736,32 @@ module MCP
|
|||
def list_resource_templates(request)
|
||||
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
||||
|
||||
{ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
|
||||
apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||
end
|
||||
|
||||
# Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`";
|
||||
# a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level
|
||||
# `ttlMs`/`cacheScope` cache hints per result (SEP-2549).
|
||||
def build_read_resource_result(handler_result)
|
||||
result = if handler_result.is_a?(Hash) && handler_result.key?(:contents)
|
||||
handler_result
|
||||
else
|
||||
{ contents: handler_result }
|
||||
end
|
||||
|
||||
apply_cache_metadata(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"`).
|
||||
# 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)
|
||||
end
|
||||
|
||||
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
||||
|
|
|
|||
|
|
@ -883,6 +883,40 @@ module MCP
|
|||
assert_equal("cursor1", result.next_cursor)
|
||||
end
|
||||
|
||||
def test_list_results_expose_ttl_ms_and_cache_scope
|
||||
# SEP-2549 cache hints are surfaced on every paginated result struct.
|
||||
[
|
||||
["tools/list", "tools", :list_tools],
|
||||
["prompts/list", "prompts", :list_prompts],
|
||||
["resources/list", "resources", :list_resources],
|
||||
["resources/templates/list", "resourceTemplates", :list_resource_templates],
|
||||
].each do |method, items_key, client_method|
|
||||
transport = mock
|
||||
mock_response = {
|
||||
"result" => { items_key => [], "ttlMs" => 5000, "cacheScope" => "private" },
|
||||
}
|
||||
transport.expects(:send_request).with do |args|
|
||||
args.dig(:request, :method) == method
|
||||
end.returns(mock_response).once
|
||||
|
||||
result = Client.new(transport: transport).public_send(client_method)
|
||||
|
||||
assert_equal(5000, result.ttl_ms, "#{method} missing ttl_ms")
|
||||
assert_equal("private", result.cache_scope, "#{method} missing cache_scope")
|
||||
end
|
||||
end
|
||||
|
||||
def test_list_results_have_nil_cache_hints_when_server_omits_them
|
||||
transport = mock
|
||||
mock_response = { "result" => { "tools" => [] } }
|
||||
transport.expects(:send_request).returns(mock_response).once
|
||||
|
||||
result = Client.new(transport: transport).list_tools
|
||||
|
||||
assert_nil(result.ttl_ms)
|
||||
assert_nil(result.cache_scope)
|
||||
end
|
||||
|
||||
def test_list_tools_with_cursor_param
|
||||
transport = mock
|
||||
|
||||
|
|
|
|||
|
|
@ -2936,6 +2936,86 @@ module MCP
|
|||
assert_empty response.dig(:result, :content)
|
||||
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.
|
||||
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"
|
||||
end
|
||||
end
|
||||
|
||||
test "resources/read carries cache hints when cache_scope is configured" do
|
||||
# The ttlMs default is 0 (do not cache), the only universally safe value.
|
||||
server = Server.new(name: "ttl_test", cache_scope: "private")
|
||||
|
||||
result = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "resources/read",
|
||||
id: 1,
|
||||
params: { uri: "file:///x" },
|
||||
})[:result]
|
||||
|
||||
assert_equal 0, result[:ttlMs]
|
||||
assert_equal "private", result[:cacheScope]
|
||||
end
|
||||
|
||||
test "results omit cache hints when ttl_ms and cache_scope are not configured" do
|
||||
# Wire-format regression: opt-in emission keeps default output unchanged.
|
||||
list_result = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result]
|
||||
read_result = @server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "resources/read",
|
||||
id: 2,
|
||||
params: { uri: "file:///x" },
|
||||
})[:result]
|
||||
|
||||
refute list_result.key?(:ttlMs)
|
||||
refute list_result.key?(:cacheScope)
|
||||
refute read_result.key?(:ttlMs)
|
||||
refute read_result.key?(:cacheScope)
|
||||
end
|
||||
|
||||
test "cache hints appear alongside nextCursor when paginating" do
|
||||
tool_a = Tool.define(name: "tool_a", description: "Tool A")
|
||||
tool_b = Tool.define(name: "tool_b", description: "Tool B")
|
||||
server = Server.new(name: "ttl_test", tools: [tool_a, tool_b], page_size: 1, ttl_ms: 1000, cache_scope: "private")
|
||||
|
||||
result = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result]
|
||||
|
||||
assert_not_nil result[:nextCursor]
|
||||
assert_equal 1000, result[:ttlMs]
|
||||
assert_equal "private", result[:cacheScope]
|
||||
end
|
||||
|
||||
test "a resources_read_handler can override the server-level cache hints per result" do
|
||||
server = Server.new(name: "ttl_test", ttl_ms: 5000)
|
||||
server.resources_read_handler do |params|
|
||||
{ contents: [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }], ttlMs: 60_000 }
|
||||
end
|
||||
|
||||
result = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "resources/read",
|
||||
id: 1,
|
||||
params: { uri: "file:///x" },
|
||||
})[:result]
|
||||
|
||||
assert_equal 60_000, result[:ttlMs]
|
||||
assert_equal "public", result[:cacheScope]
|
||||
assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents]
|
||||
end
|
||||
|
||||
test "ttl_ms and cache_scope writers reject invalid values" do
|
||||
assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: -1) }
|
||||
assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: 1.5) }
|
||||
assert_raises(ArgumentError) { Server.new(name: "ttl_test", cache_scope: "internal") }
|
||||
end
|
||||
|
||||
test "#handle tools/list returns paginated results when page_size is set" do
|
||||
tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A")
|
||||
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")
|
||||
|
|
|
|||
Loading…
Reference in a new issue