diff --git a/README.md b/README.md index b986c262..d308d96a 100644 --- a/README.md +++ b/README.md @@ -1850,6 +1850,40 @@ tools = client.tools # => Array 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 diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f32..f568b25a 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -134,7 +134,13 @@ def list_tools(cursor: nil, meta: nil, cancellation: nil) ) 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 @@ def list_resources(cursor: nil, meta: nil, cancellation: nil) resources: result["resources"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end @@ -208,6 +216,8 @@ def list_resource_templates(cursor: nil, meta: nil, cancellation: nil) resource_templates: result["resourceTemplates"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end @@ -241,6 +251,8 @@ def list_prompts(cursor: nil, meta: nil, cancellation: nil) prompts: result["prompts"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end diff --git a/lib/mcp/client/paginated_result.rb b/lib/mcp/client/paginated_result.rb index 18bd26b6..eb06ee6e 100644 --- a/lib/mcp/client/paginated_result.rb +++ b/lib/mcp/client/paginated_result.rb @@ -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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a2556401..7e2dca9b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -102,8 +102,11 @@ class ValidationError < StandardError; end 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 @@ def initialize( configuration: nil, capabilities: nil, page_size: nil, + ttl_ms: nil, + cache_scope: nil, transport: nil ) @description = description @@ -138,6 +143,8 @@ def initialize( @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 @@ def page_size=(page_size) @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 @@ def handle_request(request, method, session: nil, related_request_id: nil) 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 @@ def configure_logging_level(request, session: nil) 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 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) 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 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil 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 @@ def read_resource_no_content(request) 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) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47cf..296c0f53 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -883,6 +883,40 @@ def test_list_tools_returns_single_page_with_cursor 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 diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 22eed192..8ba7556a 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2936,6 +2936,86 @@ def server_context 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")