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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

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

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

Expand Down
12 changes: 7 additions & 5 deletions lib/mcp/client/paginated_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 59 additions & 6 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -121,6 +124,8 @@ def initialize(
configuration: nil,
capabilities: nil,
page_size: nil,
ttl_ms: nil,
cache_scope: nil,
transport: nil
)
@description = description
Expand All @@ -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

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

Expand Down Expand Up @@ -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)
{}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions test/mcp/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

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