Merge pull request #436 from koic/list_result_ttl
Add Opt-In `ttlMs` / `cacheScope` Cache Hints to List and Read Results per SEP-2549
This commit is contained in:
commit
f013bcbf8c
6 changed files with 227 additions and 12 deletions
34
README.md
34
README.md
|
|
@ -1919,6 +1919,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
|
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).
|
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
|
### Advanced
|
||||||
|
|
||||||
#### Custom Methods
|
#### Custom Methods
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,13 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Returns every tool available on the server. Iterates through all pages automatically
|
# Returns every tool available on the server. Iterates through all pages automatically
|
||||||
|
|
@ -175,6 +181,8 @@ module MCP
|
||||||
resources: result["resources"] || [],
|
resources: result["resources"] || [],
|
||||||
next_cursor: result["nextCursor"],
|
next_cursor: result["nextCursor"],
|
||||||
meta: result["_meta"],
|
meta: result["_meta"],
|
||||||
|
ttl_ms: result["ttlMs"],
|
||||||
|
cache_scope: result["cacheScope"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -208,6 +216,8 @@ module MCP
|
||||||
resource_templates: result["resourceTemplates"] || [],
|
resource_templates: result["resourceTemplates"] || [],
|
||||||
next_cursor: result["nextCursor"],
|
next_cursor: result["nextCursor"],
|
||||||
meta: result["_meta"],
|
meta: result["_meta"],
|
||||||
|
ttl_ms: result["ttlMs"],
|
||||||
|
cache_scope: result["cacheScope"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -241,6 +251,8 @@ module MCP
|
||||||
prompts: result["prompts"] || [],
|
prompts: result["prompts"] || [],
|
||||||
next_cursor: result["nextCursor"],
|
next_cursor: result["nextCursor"],
|
||||||
meta: result["_meta"],
|
meta: result["_meta"],
|
||||||
|
ttl_ms: result["ttlMs"],
|
||||||
|
cache_scope: result["cacheScope"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ module MCP
|
||||||
class Client
|
class Client
|
||||||
# Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
# 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,
|
# 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.
|
# an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549
|
||||||
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
|
# cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and
|
||||||
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
|
# `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields.
|
||||||
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
|
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
||||||
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, 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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,11 @@ module MCP
|
||||||
include Instrumentation
|
include Instrumentation
|
||||||
include Pagination
|
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_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(
|
def initialize(
|
||||||
description: nil,
|
description: nil,
|
||||||
|
|
@ -121,6 +124,8 @@ module MCP
|
||||||
configuration: nil,
|
configuration: nil,
|
||||||
capabilities: nil,
|
capabilities: nil,
|
||||||
page_size: nil,
|
page_size: nil,
|
||||||
|
ttl_ms: nil,
|
||||||
|
cache_scope: nil,
|
||||||
transport: nil
|
transport: nil
|
||||||
)
|
)
|
||||||
@description = description
|
@description = description
|
||||||
|
|
@ -138,6 +143,8 @@ module MCP
|
||||||
@resource_index = index_resources_by_uri(resources)
|
@resource_index = index_resources_by_uri(resources)
|
||||||
@server_context = server_context
|
@server_context = server_context
|
||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
|
self.ttl_ms = ttl_ms
|
||||||
|
self.cache_scope = cache_scope
|
||||||
@configuration = MCP.configuration.merge(configuration)
|
@configuration = MCP.configuration.merge(configuration)
|
||||||
@client = nil
|
@client = nil
|
||||||
|
|
||||||
|
|
@ -232,6 +239,27 @@ module MCP
|
||||||
@page_size = page_size
|
@page_size = page_size
|
||||||
end
|
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
|
def notify_tools_list_changed
|
||||||
return unless @transport
|
return unless @transport
|
||||||
|
|
||||||
|
|
@ -472,7 +500,7 @@ module MCP
|
||||||
when Methods::INITIALIZE
|
when Methods::INITIALIZE
|
||||||
init(params, session: session)
|
init(params, session: session)
|
||||||
when Methods::RESOURCES_READ
|
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
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
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)
|
def list_tools(request)
|
||||||
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
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
|
end
|
||||||
|
|
||||||
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
||||||
|
|
@ -667,7 +695,7 @@ module MCP
|
||||||
def list_prompts(request)
|
def list_prompts(request)
|
||||||
page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
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
|
end
|
||||||
|
|
||||||
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
||||||
|
|
@ -696,7 +724,7 @@ module MCP
|
||||||
def list_resources(request)
|
def list_resources(request)
|
||||||
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
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
|
end
|
||||||
|
|
||||||
# Server implementation should set `resources_read_handler` to override no-op default
|
# Server implementation should set `resources_read_handler` to override no-op default
|
||||||
|
|
@ -708,7 +736,32 @@ module MCP
|
||||||
def list_resource_templates(request)
|
def list_resource_templates(request)
|
||||||
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
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
|
end
|
||||||
|
|
||||||
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
||||||
|
|
|
||||||
|
|
@ -883,6 +883,40 @@ module MCP
|
||||||
assert_equal("cursor1", result.next_cursor)
|
assert_equal("cursor1", result.next_cursor)
|
||||||
end
|
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
|
def test_list_tools_with_cursor_param
|
||||||
transport = mock
|
transport = mock
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2936,6 +2936,86 @@ module MCP
|
||||||
assert_empty response.dig(:result, :content)
|
assert_empty response.dig(:result, :content)
|
||||||
end
|
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
|
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_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")
|
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue