ruby-sdk/test/mcp/client_test.rb
Koichi ITO f3d6da2371 Support client-side sampling via MCP::Client#on_sampling
## Motivation and Context

The MCP specification defines `sampling/createMessage` as a server-to-client request
that lets a server ask the client to generate an LLM completion, so the server can leverage
the client's model access without its own API keys.
The Ruby SDK already supports the server side (sending the request) and, on the client side,
the generic server-to-client request infrastructure introduced for elicitation
(the standalone GET SSE listening stream and `on_server_request` dispatch).
This adds the client-side `sampling/createMessage` handler on top of that infrastructure.

## Changes

- Add `MCP::Client#on_sampling`, a thin wrapper that registers a `sampling/createMessage` handler
  via `transport.on_server_request`, mirroring `on_elicitation`. The handler receives the request params
  and returns a `CreateMessageResult`-shaped Hash sent back as the JSON-RPC result.
- Document client-side sampling in the README, including the `sampling` (and `sampling.tools`)
  capability declaration and the human-in-the-loop guidance from the specification.

## How Has This Been Tested?

- Unit tests for `on_sampling` covering handler registration on the transport
  and the error raised when the transport does not support server-to-client requests.
- An HTTP transport test that dispatches a `sampling/createMessage` server request delivered on
  the SSE stream and returns a `CreateMessageResult`.

## Deprecation Note

SEP-2577 deprecates sampling (along with roots and logging) starting with protocol version `2026-07-28`.
The deprecation warnings are added in https://github.com/modelcontextprotocol/ruby-sdk/pull/406.
Sampling remains fully supported under the current `2025-11-25` protocol version and stays available
throughout the spec's deprecation window, so the client side is implemented here for parity with
the server side and with the Python and TypeScript SDKs.

`MCP::Client#on_sampling` carries the same `@deprecated` annotation as the server-side senders
annotated in #429, tailored to the receiving side: the handler exists to interoperate with servers
that still send sampling requests during the deprecation window. The README section carries
the same note.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
2026-07-15 01:51:41 +09:00

1626 lines
59 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
require "securerandom"
module MCP
class ClientTest < Minitest::Test
def test_connect_delegates_to_transport_and_forwards_keyword_args
transport = mock
init_result = {
"protocolVersion" => "2025-11-25",
"capabilities" => { "tools" => {} },
"serverInfo" => { "name" => "test-server", "version" => "1.0" },
}
transport.expects(:connect).with(
client_info: { name: "my-app", version: "1.0" },
protocol_version: "2025-11-25",
capabilities: { roots: {} },
).returns(init_result).once
result = Client.new(transport: transport).connect(
client_info: { name: "my-app", version: "1.0" },
protocol_version: "2025-11-25",
capabilities: { roots: {} },
)
assert_equal(init_result, result)
end
def test_connect_passes_nil_defaults_to_transport
transport = mock
transport.expects(:connect)
.with(client_info: nil, protocol_version: nil, capabilities: {})
.returns({ "protocolVersion" => "2025-11-25" }).once
Client.new(transport: transport).connect
end
def test_connect_is_noop_when_transport_does_not_respond_to_connect
transport = mock
transport.stubs(:respond_to?).with(:connect).returns(false)
transport.stubs(:respond_to?).with(:server_info).returns(false)
client = Client.new(transport: transport)
assert_nil(client.connect)
assert_nil(client.server_info)
end
def test_on_elicitation_registers_handler_on_transport
transport = mock
handler = proc { { action: "accept", content: {} } }
transport.expects(:on_server_request).with("elicitation/create")
Client.new(transport: transport).on_elicitation(&handler)
end
def test_on_elicitation_raises_when_transport_does_not_support_server_requests
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
error = assert_raises(ArgumentError) do
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
end
assert_includes(error.message, "does not support server-to-client requests")
end
def test_on_sampling_registers_handler_on_transport
transport = mock
handler = proc { { role: "assistant", content: { type: "text", text: "hi" } } }
transport.expects(:on_server_request).with("sampling/createMessage")
Client.new(transport: transport).on_sampling(&handler)
end
def test_on_sampling_raises_when_transport_does_not_support_server_requests
transport = mock
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
error = assert_raises(ArgumentError) do
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
end
assert_includes(error.message, "does not support server-to-client requests")
end
def test_connected_delegates_to_transport_when_supported
transport = mock
transport.expects(:connected?).returns(true)
assert_predicate(Client.new(transport: transport), :connected?)
end
def test_connected_returns_true_when_transport_does_not_respond_to_connected
transport = mock
transport.stubs(:respond_to?).with(:connected?).returns(false)
assert_predicate(Client.new(transport: transport), :connected?)
end
def test_server_info_delegates_to_transport_when_supported
transport = mock
init_result = { "protocolVersion" => "2025-11-25" }
transport.expects(:server_info).returns(init_result)
assert_equal(init_result, Client.new(transport: transport).server_info)
end
def test_server_info_returns_nil_when_transport_does_not_expose_it
transport = mock
transport.stubs(:respond_to?).with(:server_info).returns(false)
assert_nil(Client.new(transport: transport).server_info)
end
def test_tools_sends_request_to_transport_and_returns_tools_array
transport = mock
mock_response = {
"result" => {
"tools" => [
{
"name" => "tool1",
"description" => "tool1",
"inputSchema" => {},
"outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } },
},
{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
tools = client.tools
assert_equal(2, tools.size)
assert_equal("tool1", tools.first.name)
assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema)
assert_equal("tool2", tools.last.name)
assert_nil(tools.last.output_schema)
end
def test_tools_returns_empty_array_when_no_tools
transport = mock
mock_response = { "result" => { "tools" => [] } }
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
tools = client.tools
assert_equal([], tools)
end
def test_call_tool_sends_request_to_transport_and_returns_content
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: arguments)
content = result.dig("result", "content")
assert_equal([{ type: "text", text: "Hello, world!" }], content)
end
def test_call_tool_by_name
transport = mock
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(name: "tool1", arguments: arguments)
content = result.dig("result", "content")
assert_equal([{ type: "text", text: "Hello, world!" }], content)
end
def test_call_tool_raises_input_required_error_when_result_type_is_input_required
# Per SEP-2322, a result with `resultType: "input_required"` is not a final result;
# surface it instead of returning it as a normal result.
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => {
"resultType" => "input_required",
"inputRequests" => {
"1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } },
},
"requestState" => "opaque-state",
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::InputRequiredError) do
client.call_tool(tool: tool, arguments: {})
end
assert_equal(
{ "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } } },
error.input_requests,
)
assert_equal("opaque-state", error.request_state)
assert_equal(mock_response["result"], error.result)
end
def test_call_tool_returns_normally_when_result_type_is_complete
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => {
"resultType" => "complete",
"content" => [{ "type" => "text", "text" => "done" }],
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: {})
assert_equal("done", result.dig("result", "content", 0, "text"))
end
def test_call_tool_returns_normally_when_result_type_is_absent
# Regression guard: stable-protocol servers never send `resultType`.
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => { "content" => [{ "type" => "text", "text" => "done" }] },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: {})
assert_equal("done", result.dig("result", "content", 0, "text"))
end
def test_list_tools_raises_input_required_error_for_input_required_results
# The recognition lives in the shared request path, so every client method is covered, not only tools/call.
transport = mock
mock_response = {
"result" => { "resultType" => "input_required", "inputRequests" => {} },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::InputRequiredError) { client.list_tools }
end
def test_call_tool_raises_when_no_name_or_tool
client = Client.new(transport: mock)
error = assert_raises(ArgumentError) { client.call_tool(arguments: { foo: "bar" }) }
assert_equal("Either `name:` or `tool:` must be provided.", error.message)
end
def test_resources_sends_request_to_transport_and_returns_resources_array
transport = mock
mock_response = {
"result" => {
"resources" => [
{ "name" => "resource1", "uri" => "file:///path/to/resource1", "description" => "First resource" },
{ "name" => "resource2", "uri" => "file:///path/to/resource2", "description" => "Second resource" },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
resources = client.resources
assert_equal(2, resources.size)
assert_equal("resource1", resources.first["name"])
assert_equal("file:///path/to/resource1", resources.first["uri"])
assert_equal("resource2", resources.last["name"])
assert_equal("file:///path/to/resource2", resources.last["uri"])
end
def test_resources_returns_empty_array_when_no_resources
transport = mock
mock_response = { "result" => { "resources" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
resources = client.resources
assert_equal([], resources)
end
def test_read_resource_sends_request_to_transport_and_returns_contents
transport = mock
uri = "file:///path/to/resource.txt"
mock_response = {
"result" => {
"contents" => [
{ "uri" => uri, "mimeType" => "text/plain", "text" => "Hello, world!" },
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/read" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :uri) == uri
end.returns(mock_response).once
client = Client.new(transport: transport)
contents = client.read_resource(uri: uri)
assert_equal(1, contents.size)
assert_equal(uri, contents.first["uri"])
assert_equal("text/plain", contents.first["mimeType"])
assert_equal("Hello, world!", contents.first["text"])
end
def test_read_resource_returns_empty_array_when_no_contents
transport = mock
uri = "file:///path/to/nonexistent.txt"
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.read_resource(uri: uri)
assert_equal([], contents)
end
def test_resource_templates_sends_request_to_transport_and_returns_resource_templates_array
transport = mock
mock_response = {
"result" => {
"resourceTemplates" => [
{ "name" => "template1", "uriTemplate" => "file:///path/{filename}", "description" => "First template" },
{ "name" => "template2", "uriTemplate" => "http://example.com/{id}", "description" => "Second template" },
],
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/templates/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
resource_templates = client.resource_templates
assert_equal(2, resource_templates.size)
assert_equal("template1", resource_templates.first["name"])
assert_equal("file:///path/{filename}", resource_templates.first["uriTemplate"])
assert_equal("template2", resource_templates.last["name"])
assert_equal("http://example.com/{id}", resource_templates.last["uriTemplate"])
end
def test_resource_templates_returns_empty_array_when_no_resource_templates
transport = mock
mock_response = { "result" => { "resourceTemplates" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
resource_templates = client.resource_templates
assert_equal([], resource_templates)
end
def test_prompts_sends_request_to_transport_and_returns_prompts_array
transport = mock
mock_response = {
"result" => {
"prompts" => [
{
"name" => "prompt_1",
"description" => "First prompt",
"arguments" => [
{
"name" => "code_1",
"description" => "The code_1 to review",
"required" => true,
},
],
},
{
"name" => "prompt_2",
"description" => "Second prompt",
"arguments" => [
{
"name" => "code_2",
"description" => "The code_2 to review",
"required" => true,
},
],
},
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "prompts/list" && args.dig(:request, :jsonrpc) == "2.0"
end.returns(mock_response).once
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal(2, prompts.size)
assert_equal("prompt_1", prompts.first["name"])
assert_equal("First prompt", prompts.first["description"])
assert_equal("code_1", prompts.first["arguments"].first["name"])
assert_equal("The code_1 to review", prompts.first["arguments"].first["description"])
assert(prompts.first["arguments"].first["required"])
assert_equal("prompt_2", prompts.last["name"])
assert_equal("Second prompt", prompts.last["description"])
assert_equal("code_2", prompts.last["arguments"].first["name"])
assert_equal("The code_2 to review", prompts.last["arguments"].first["description"])
assert(prompts.last["arguments"].first["required"])
end
def test_prompts_returns_empty_array_when_no_prompts
transport = mock
mock_response = { "result" => { "prompts" => [] } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal([], prompts)
end
def test_get_prompt_sends_request_to_transport_and_returns_contents
transport = mock
name = "first_prompt"
mock_response = {
"result" => {
"description" => "First prompt",
"messages" => [
{
"role" => "user",
"content" => {
"text" => "First prompt content",
"type" => "text",
},
},
],
},
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "prompts/get" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :name) == name
end.returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal("First prompt", contents["description"])
assert_equal("user", contents["messages"].first["role"])
assert_equal("First prompt content", contents["messages"].first["content"]["text"])
end
def test_get_prompt_returns_empty_hash_when_no_contents
transport = mock
name = "nonexistent_prompt"
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal({}, contents)
end
def test_get_prompt_returns_empty_hash
transport = mock
name = "nonexistent_prompt"
mock_response = {}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
contents = client.get_prompt(name: name)
assert_equal({}, contents)
end
def test_call_tool_includes_meta_progress_token_when_provided
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
progress_token = "my-progress-token"
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :_meta, :progressToken) == progress_token &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
end
def test_call_tool_sends_trace_context_meta_entries
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = {
MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
MCP::TraceContext::TRACESTATE_META_KEY => "vendor=value",
MCP::TraceContext::BAGGAGE_META_KEY => "userId=alice",
}
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
sent_meta = args.dig(:request, :params, :_meta)
sent_meta["traceparent"] == meta["traceparent"] &&
sent_meta["tracestate"] == meta["tracestate"] &&
sent_meta["baggage"] == meta["baggage"]
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, meta: meta)
end
def test_call_tool_merges_meta_with_progress_token_taking_precedence
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = { "traceparent" => "00-trace-span-01", "progressToken" => "from-meta" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
sent_meta = args.dig(:request, :params, :_meta)
sent_meta["traceparent"] == "00-trace-span-01" &&
sent_meta[:progressToken] == "explicit-token" &&
!sent_meta.key?("progressToken")
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, progress_token: "explicit-token", meta: meta)
end
def test_call_tool_omits_meta_when_empty_meta_hash_given
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params).key?(:_meta) == false
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, meta: {})
end
def test_call_tool_does_not_mutate_caller_meta
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
meta = { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: {}, progress_token: "t", meta: meta)
assert_equal({ "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }, meta)
end
def test_read_resource_sends_meta_when_provided
transport = mock
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
mock_response = { "result" => { "contents" => [] } }
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "resources/read" &&
args.dig(:request, :params, :uri) == "file:///foo" &&
args.dig(:request, :params, :_meta, :traceparent) == traceparent
end.returns(mock_response).once
client = Client.new(transport: transport)
client.read_resource(uri: "file:///foo", meta: { traceparent: traceparent })
end
def test_request_methods_send_meta_when_provided
# Per SEP-414, trace context should flow on every request, so the `meta:` keyword
# is available on all client request methods.
traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
meta = { traceparent: traceparent }
[
["tools/list", { "tools" => [] }, ->(client) { client.list_tools(meta: meta) }],
["resources/list", { "resources" => [] }, ->(client) { client.list_resources(meta: meta) }],
["resources/templates/list", { "resourceTemplates" => [] }, ->(client) { client.list_resource_templates(meta: meta) }],
["prompts/list", { "prompts" => [] }, ->(client) { client.list_prompts(meta: meta) }],
["prompts/get", {}, ->(client) { client.get_prompt(name: "p", meta: meta) }],
[
"completion/complete",
{ "completion" => { "values" => [] } },
->(client) {
client.complete(ref: { type: "ref/prompt", name: "p" }, argument: { name: "a", value: "v" }, meta: meta)
},
],
["ping", {}, ->(client) { client.ping(meta: meta) }],
].each do |method, result, invoke|
transport = mock
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == method &&
args.dig(:request, :params, :_meta, :traceparent) == traceparent
end.returns({ "result" => result }).once
invoke.call(Client.new(transport: transport))
end
end
def test_request_methods_omit_meta_when_not_provided
# Wire-format regression: without `meta:`, list requests keep sending no `params` at all.
transport = mock
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list" && !args[:request].key?(:params)
end.returns({ "result" => { "tools" => [] } }).once
Client.new(transport: transport).list_tools
end
def test_call_tool_omits_meta_when_no_progress_token
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params).key?(:_meta) == false
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments)
end
def test_tools_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.tools }
assert_equal(-32_601, error.code)
assert_equal("Method not found", error.message)
end
def test_resources_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.resources }
assert_equal(-32_602, error.code)
end
def test_read_resource_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") }
end
def test_read_resource_surfaces_resource_not_found_code_and_data
# Per SEP-2164, servers report unknown resource URIs with -32602 and
# the URI in the error `data`; the client must expose both unmodified.
transport = mock
mock_response = {
"error" => {
"code" => -32_602,
"message" => "Resource not found: file:///missing.txt",
"data" => { "uri" => "file:///missing.txt" },
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing.txt") }
assert_equal(-32_602, error.code)
assert_equal({ "uri" => "file:///missing.txt" }, error.data)
end
def test_get_prompt_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.get_prompt(name: "missing") }
end
def test_prompts_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.prompts }
end
def test_resource_templates_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
assert_raises(Client::ServerError) { client.resource_templates }
end
def test_call_tool_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") }
assert_equal(-32_602, error.code)
end
def test_server_error_includes_data_field
transport = mock
mock_response = {
"error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" },
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.tools }
assert_equal("extra details", error.data)
end
def test_complete_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) }
assert_equal(-32_602, error.code)
end
def test_complete_sends_request_and_returns_completion_result
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["python", "pytorch"],
"hasMore" => false,
},
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "completion/complete" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } &&
args.dig(:request, :params, :argument) == { name: "language", value: "py" } &&
!args.dig(:request, :params).key?(:context)
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "language", value: "py" },
)
assert_equal(["python", "pytorch"], result["values"])
refute(result["hasMore"])
end
def test_complete_includes_context_when_provided
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["flask"],
"hasMore" => false,
},
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params, :context) == { arguments: { language: "python" } }
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "framework", value: "fla" },
context: { arguments: { language: "python" } },
)
assert_equal(["flask"], result["values"])
end
def test_complete_returns_default_when_result_is_missing
transport = mock
mock_response = { "result" => {} }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "test" },
argument: { name: "arg", value: "" },
)
assert_equal([], result["values"])
refute(result["hasMore"])
end
def test_ping_sends_request_and_returns_empty_hash
transport = mock
mock_response = { "result" => {} }
transport.expects(:send_request).with do |args|
req = args[:request]
req[:method] == "ping" &&
req[:jsonrpc] == "2.0" &&
!req.key?(:params)
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.ping
assert_equal({}, result)
end
def test_ping_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_603, "message" => "Internal error" } }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.ping }
assert_equal(-32_603, error.code)
assert_equal("Internal error", error.message)
end
def test_ping_raises_validation_error_when_result_is_missing
transport = mock
mock_response = {}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ValidationError) { client.ping }
assert_equal("Response validation failed: missing or invalid `result`", error.message)
end
def test_ping_raises_validation_error_when_result_is_wrong_type
transport = mock
mock_response = { "result" => "ok" }
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
error = assert_raises(Client::ValidationError) { client.ping }
assert_equal("Response validation failed: missing or invalid `result`", error.message)
end
def test_ping_propagates_transport_errors
transport = mock
transport_error = StandardError.new("read timeout")
transport.expects(:send_request).raises(transport_error).once
client = Client.new(transport: transport)
error = assert_raises(StandardError) { client.ping }
assert_equal("read timeout", error.message)
end
def test_tools_auto_paginates_across_multiple_pages
transport = mock
page1_response = {
"result" => {
"tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }],
"nextCursor" => "cursor1",
},
}
page2_response = {
"result" => {
"tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }],
},
}
call_count = 0
transport.expects(:send_request).twice.with do |args|
call_count += 1
req = args[:request]
if call_count == 1
req[:method] == "tools/list" && req[:params].nil?
else
req[:method] == "tools/list" && req[:params] == { cursor: "cursor1" }
end
end.returns(page1_response).then.returns(page2_response)
client = Client.new(transport: transport)
tools = client.tools
assert_equal(2, tools.size)
assert_equal("tool1", tools[0].name)
assert_equal("tool2", tools[1].name)
end
def test_list_tools_returns_single_page_with_cursor
transport = mock
mock_response = {
"result" => {
"tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }],
"nextCursor" => "cursor1",
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/list"
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.list_tools
assert_equal(1, result.tools.size)
assert_equal("tool1", result.tools[0].name)
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
mock_response = {
"result" => {
"tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }],
},
}
transport.expects(:send_request).with do |args|
args.dig(:request, :params) == { cursor: "cursor1" }
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.list_tools(cursor: "cursor1")
assert_equal(1, result.tools.size)
assert_equal("tool2", result.tools[0].name)
assert_nil(result.next_cursor)
end
def test_resources_auto_paginates_across_multiple_pages
transport = mock
page1_response = {
"result" => {
"resources" => [{ "uri" => "https://a.invalid", "name" => "a" }],
"nextCursor" => "cursor1",
},
}
page2_response = {
"result" => {
"resources" => [{ "uri" => "https://b.invalid", "name" => "b" }],
},
}
transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response)
client = Client.new(transport: transport)
resources = client.resources
assert_equal(2, resources.size)
assert_equal("a", resources[0]["name"])
assert_equal("b", resources[1]["name"])
end
def test_list_resources_returns_single_page_with_cursor
transport = mock
mock_response = {
"result" => {
"resources" => [{ "uri" => "https://a.invalid", "name" => "a" }],
"nextCursor" => "cursor1",
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.list_resources
assert_equal(1, result.resources.size)
assert_equal("cursor1", result.next_cursor)
end
def test_resource_templates_auto_paginates_across_multiple_pages
transport = mock
page1_response = {
"result" => {
"resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }],
"nextCursor" => "cursor1",
},
}
page2_response = {
"result" => {
"resourceTemplates" => [{ "uriTemplate" => "https://b.invalid/{id}", "name" => "b" }],
},
}
transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response)
client = Client.new(transport: transport)
templates = client.resource_templates
assert_equal(2, templates.size)
assert_equal("a", templates[0]["name"])
assert_equal("b", templates[1]["name"])
end
def test_list_resource_templates_returns_single_page_with_cursor
transport = mock
mock_response = {
"result" => {
"resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }],
"nextCursor" => "cursor1",
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.list_resource_templates
assert_equal(1, result.resource_templates.size)
assert_equal("cursor1", result.next_cursor)
end
def test_prompts_auto_paginates_across_multiple_pages
transport = mock
page1_response = {
"result" => {
"prompts" => [{ "name" => "prompt_a", "description" => "A" }],
"nextCursor" => "cursor1",
},
}
page2_response = {
"result" => {
"prompts" => [{ "name" => "prompt_b", "description" => "B" }],
},
}
transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response)
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal(2, prompts.size)
assert_equal("prompt_a", prompts[0]["name"])
assert_equal("prompt_b", prompts[1]["name"])
end
def test_tools_breaks_when_server_returns_same_cursor_repeatedly
transport = mock
stuck_response = {
"result" => {
"tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }],
"nextCursor" => "stuck_cursor",
},
}
# If the server keeps returning the same cursor, the client must not loop forever.
# Expect at most 2 calls: the initial request (cursor=nil) and one retry (cursor="stuck_cursor")
# that detects the repeat and breaks out.
transport.expects(:send_request).twice.returns(stuck_response)
client = Client.new(transport: transport)
tools = client.tools
assert_equal(2, tools.size)
end
def test_tools_breaks_when_server_cycles_between_cursors
transport = mock
page_a = {
"result" => {
"tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }],
"nextCursor" => "A",
},
}
page_b = {
"result" => {
"tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }],
"nextCursor" => "B",
},
}
# Server cycles A -> B -> A. Client must detect the revisited cursor and break.
page_a_again = {
"result" => {
"tools" => [{ "name" => "tool3", "description" => "tool3", "inputSchema" => {} }],
"nextCursor" => "A",
},
}
transport.expects(:send_request).times(3).returns(page_a, page_b, page_a_again)
client = Client.new(transport: transport)
tools = client.tools
assert_equal(3, tools.size)
assert_equal(["tool1", "tool2", "tool3"], tools.map(&:name))
end
def test_resources_breaks_when_server_returns_same_cursor_repeatedly
transport = mock
stuck_response = {
"result" => {
"resources" => [{ "uri" => "https://a.invalid", "name" => "a" }],
"nextCursor" => "stuck_cursor",
},
}
transport.expects(:send_request).twice.returns(stuck_response)
client = Client.new(transport: transport)
resources = client.resources
assert_equal(2, resources.size)
end
def test_resource_templates_breaks_when_server_returns_same_cursor_repeatedly
transport = mock
stuck_response = {
"result" => {
"resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }],
"nextCursor" => "stuck_cursor",
},
}
transport.expects(:send_request).twice.returns(stuck_response)
client = Client.new(transport: transport)
templates = client.resource_templates
assert_equal(2, templates.size)
end
def test_prompts_breaks_when_server_returns_same_cursor_repeatedly
transport = mock
stuck_response = {
"result" => {
"prompts" => [{ "name" => "prompt_a", "description" => "A" }],
"nextCursor" => "stuck_cursor",
},
}
transport.expects(:send_request).twice.returns(stuck_response)
client = Client.new(transport: transport)
prompts = client.prompts
assert_equal(2, prompts.size)
end
def test_list_prompts_returns_single_page_with_cursor
transport = mock
mock_response = {
"result" => {
"prompts" => [{ "name" => "prompt_a", "description" => "A" }],
"nextCursor" => "cursor1",
},
}
transport.expects(:send_request).returns(mock_response).once
client = Client.new(transport: transport)
result = client.list_prompts
assert_equal(1, result.prompts.size)
assert_equal("cursor1", result.next_cursor)
end
def test_cancellation_without_reason_omits_reason_in_notification
# The token-driven cancel path must omit `reason` from the `notifications/cancelled` params
# when the token is cancelled without one.
sent = Queue.new
cancellation = MCP::Cancellation.new
transport = Object.new
transport.define_singleton_method(:send_request) do |**|
cancellation.cancel # no reason
{ "result" => {} }
end
transport.define_singleton_method(:send_notification) do |notification:|
sent.push(notification)
nil
end
client = Client.new(transport: transport)
assert_raises(MCP::CancelledError) do
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
end
notification = sent.pop
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
refute(notification[:params].key?(:reason))
end
def test_call_tool_with_cancellation_returns_response_when_no_cancel
transport = mock
transport.expects(:send_request).returns({ "result" => { "content" => [{ "type" => "text", "text" => "ok" }] } }).once
transport.stubs(:send_notification) # required for `cancellation:` capability check
cancellation = MCP::Cancellation.new
client = Client.new(transport: transport)
response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
assert_equal("ok", response.dig("result", "content", 0, "text"))
end
def test_call_tool_with_cancellation_already_cancelled_raises_without_sending
transport = mock
transport.expects(:send_request).never
transport.expects(:send_notification).never
cancellation = MCP::Cancellation.new
cancellation.cancel(reason: "pre-cancelled")
client = Client.new(transport: transport)
assert_raises(MCP::CancelledError) do
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
end
end
def test_call_tool_with_cancellation_mid_flight_raises_and_sends_notification
cancellation = MCP::Cancellation.new
notification_queue = Queue.new
transport = Object.new
transport.define_singleton_method(:send_request) do |**|
# Cancel while the request is "in flight".
cancellation.cancel(reason: "user abort")
sleep(0.05)
{ "result" => {} }
end
transport.define_singleton_method(:send_notification) do |notification:|
notification_queue.push(notification)
nil
end
client = Client.new(transport: transport)
error = assert_raises(MCP::CancelledError) do
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
end
assert_equal("user abort", error.reason)
# Cancel notification is dispatched fire-and-forget on a separate thread;
# block here until it reaches the transport.
notification = notification_queue.pop
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
assert_equal("user abort", notification[:params][:reason])
end
def test_loop_methods_accept_cancellation_keyword
# Regression: `tools` / `resources` / `resource_templates` / `prompts` are the high-level pagination loops.
# They must accept `cancellation:` so the README's "all request methods accept the cancellation: keyword"
# claim holds. The token is propagated to each underlying `list_*` page.
cancellation = MCP::Cancellation.new
transport = mock
transport.stubs(:send_request).returns({ "result" => { "tools" => [] } })
transport.stubs(:send_notification) # required for `cancellation:` capability check
client = Client.new(transport: transport)
[
-> { client.tools(cancellation: cancellation) },
-> { client.resources(cancellation: cancellation) },
-> { client.resource_templates(cancellation: cancellation) },
-> { client.prompts(cancellation: cancellation) },
].each do |loop_call|
transport.stubs(:send_request).returns({ "result" => {} })
loop_call.call
end
end
def test_cancel_callback_does_not_deadlock_when_transport_holds_a_mutex
# Regression: when the cancellation callback fires on the same thread that is currently inside
# `transport.send_request` (and therefore holds a transport-level mutex), a synchronous `send_notification`
# from the callback would deadlock by re-entering the same mutex. The fix wakes the calling thread first
# and dispatches the notification on a separate thread.
transport_mutex = Mutex.new
cancellation = MCP::Cancellation.new
# Custom transport class so we can model a mutex held during send_request.
transport = Object.new
transport.define_singleton_method(:send_request) do |**|
transport_mutex.synchronize do
# Trigger cancel while we hold the mutex. The callback must NOT try to synchronously acquire
# the same mutex via send_notification.
cancellation.cancel(reason: "mid-flight")
# Hold the mutex briefly to give the callback time to (incorrectly) try to re-enter.
sleep(0.05)
{ "result" => {} }
end
end
# send_notification also needs the mutex - this is the path that would deadlock under synchronous dispatch.
send_notification_called = Queue.new
transport.define_singleton_method(:send_notification) do |notification:|
transport_mutex.synchronize do
send_notification_called.push(notification)
end
end
client = Client.new(transport: transport)
assert_raises(MCP::CancelledError) do
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
end
# The notification must reach the transport eventually (after the mutex is released by the now-finished send_request).
notification = send_notification_called.pop
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
assert_equal("mid-flight", notification[:params][:reason])
end
def test_late_cancel_after_response_does_not_send_stray_notification
# Regression: between the worker thread pushing `:response` and the main thread leaving `dispatch_with_cancellation`,
# a cancellation firing in that gap must not emit a stray `notifications/cancelled` for the already-completed request.
# The completion-mutex gate makes the worker and the on_cancel callback fight for a single slot; whichever sets
# `completed` first wins, the loser bails.
cancellation = MCP::Cancellation.new
notification_count = 0
notification_count_mutex = Mutex.new
transport = Object.new
transport.define_singleton_method(:send_request) do |**, &on_sent|
on_sent&.call
{ "result" => {} }
end
transport.define_singleton_method(:send_notification) do |**|
notification_count_mutex.synchronize { notification_count += 1 }
nil
end
client = Client.new(transport: transport)
response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
assert_equal({}, response["result"])
# Cancel after the call returned. The on_cancel hook was deregistered in the `ensure` block;
# even if a callback raced with the response handoff, `completed` would already be true and
# `queue.push :cancelled` plus the cancel-dispatch thread would not run.
cancellation.cancel(reason: "late")
sleep(0.05) # let any erroneous cancel-dispatch thread complete.
count = notification_count_mutex.synchronize { notification_count }
assert_equal(0, count, "no stray notifications/cancelled for an already-completed request")
end
def test_call_tool_with_cancellation_raises_NoMethodError_when_transport_lacks_send_notification
# Regression: a transport that only implements `send_request` cannot deliver `notifications/cancelled`.
# We must surface this upfront with a clear `NoMethodError` rather than silently swallow
# the failure inside the cancel-dispatch thread - otherwise the user sees `CancelledError` but
# the server never receives the notification.
transport = Object.new
transport.define_singleton_method(:send_request) do |**|
{ "result" => {} }
end
# NOTE: no send_notification.
cancellation = MCP::Cancellation.new
client = Client.new(transport: transport)
error = assert_raises(NoMethodError) do
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
end
assert_match(/send_notification/, error.message)
end
def test_cancel_notification_is_dispatched_after_request_write_via_on_sent_block
# Regression for the wire-order race: a transport that yields from `send_request` after writing
# the request must see `:request` recorded before `:cancel` even when the cancel signal arrives concurrently.
recorded = []
recorded_mutex = Mutex.new
cancellation = MCP::Cancellation.new
cancel_complete = Queue.new
transport = Object.new
transport.define_singleton_method(:send_request) do |request:, &on_sent|
# Trigger cancel BEFORE the simulated wire-write so the cancel-dispatch thread starts racing immediately.
# The `on_sent` block is yielded only after we have recorded the request - so any cancel-write that obeys
# the block must land afterwards.
cancellation.cancel(reason: "race")
sleep(0.02) # give cancel-dispatch thread time to spawn and start waiting
recorded_mutex.synchronize { recorded << [:request, request[:id]] }
on_sent&.call
{ "result" => {} }
end
transport.define_singleton_method(:send_notification) do |notification:|
recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] }
cancel_complete.push(:done)
nil
end
client = Client.new(transport: transport)
client.stubs(:generate_request_id).returns("req-A")
assert_raises(MCP::CancelledError) do
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
end
cancel_complete.pop # wait for the fire-and-forget cancel dispatch
# Wire-order must be: request first, then cancel.
assert_equal([[:request, "req-A"], [:cancel, "req-A"]], recorded)
end
def test_cancel_notification_waits_for_delayed_on_sent_signal
# Regression: the cancel-dispatch thread must wait for `&on_sent` no matter how long it takes -
# it cannot fall back to wall-clock-based timeout. An earlier 100 ms fixed-duration fallback
# could release the cancel thread before the worker reached its send-boundary at all, allowing
# the cancel to be issued without any prior request commitment - which the spec only covers under
# the receiver's MAY-ignore-unknown-id clause. The wait is now bounded by worker termination
# (via `ensure -> signal_sent.call`), not by elapsed time.
recorded = []
recorded_mutex = Mutex.new
cancellation = MCP::Cancellation.new
cancel_complete = Queue.new
transport = Object.new
transport.define_singleton_method(:send_request) do |request:, &on_sent|
cancellation.cancel(reason: "race")
# Delay well beyond any plausible fixed-duration fallback (200 ms).
# If cancel-dispatch fires on a timer rather than waiting for on_sent,
# the recorded order will be [:cancel, :request] and the assertion below will fail.
sleep(0.2)
recorded_mutex.synchronize { recorded << [:request, request[:id]] }
on_sent&.call
{ "result" => {} }
end
transport.define_singleton_method(:send_notification) do |notification:|
recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] }
cancel_complete.push(:done)
nil
end
client = Client.new(transport: transport)
client.stubs(:generate_request_id).returns("req-D")
assert_raises(MCP::CancelledError) do
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
end
cancel_complete.pop
assert_equal([[:request, "req-D"], [:cancel, "req-D"]], recorded)
end
def test_cancel_notification_dispatches_when_worker_raises_before_on_sent
# Regression: if the worker raises before `&on_sent` is called - which can happen if a transport rejects
# a malformed request synchronously - the cancel-dispatch thread must still terminate AND deliver the notification.
# The worker's `ensure` block invokes `signal_sent.call`, which broadcasts to the condition variable and unblocks
# the `until request_sent` wait. Without the ensure-driven unblock, the dispatch thread would leak.
cancellation = MCP::Cancellation.new
send_notification_called = Queue.new
transport = Object.new
transport.define_singleton_method(:send_request) do |**|
# Trigger cancel before raising so the cancel hook fires while the worker is mid-flight,
# then bail out without ever calling on_sent.
cancellation.cancel(reason: "race")
raise StandardError, "transport boom"
end
transport.define_singleton_method(:send_notification) do |notification:|
send_notification_called.push(notification)
nil
end
client = Client.new(transport: transport)
client.stubs(:generate_request_id).returns("req-E")
assert_raises(MCP::CancelledError) do
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
end
# The cancel-dispatch thread should still send the notification because the worker's `ensure` triggers `signal_sent.call`.
notification = send_notification_called.pop
assert_equal("req-E", notification[:params][:requestId])
end
def test_call_tool_with_cancellation_normal_completion_does_not_send_notification
transport = mock
transport.expects(:send_request).returns({ "result" => {} }).once
transport.expects(:send_notification).never
cancellation = MCP::Cancellation.new
client = Client.new(transport: transport)
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
# Late cancel after normal completion: hook should already be deregistered.
cancellation.cancel(reason: "late")
end
end
end