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:
Koichi ITO 2026-07-02 17:51:01 +09:00
commit 3339ceb8c1
2 changed files with 227 additions and 12 deletions

View file

@ -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

View file

@ -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")