ruby-sdk/test/mcp/server_cancellation_test.rb
Koichi ITO 48b7aaacd9 Stop negotiating modern protocol versions through the initialize handshake
## Motivation and Context

Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761

A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.

The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.

`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.

The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.

Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).

A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.

Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.

`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.

The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.

## How Has This Been Tested?

New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.

Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.

Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.

The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.

## Breaking Changes

Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
2026-08-14 16:08:07 +09:00

717 lines
25 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class ServerCancellationTest < ActiveSupport::TestCase
include InstrumentationTestHelper
class MockTransport < Transport
attr_reader :requests, :notifications, :cancelled_request_ids
def initialize(server)
super
@requests = []
@notifications = []
@cancelled_request_ids = []
end
def send_request(method, params = nil, **_kwargs)
@requests << { method: method, params: params }
{}
end
def send_notification(method, params = nil, **_kwargs)
@notifications << { method: method, params: params }
true
end
def cancel_pending_request(request_id, reason: nil)
@cancelled_request_ids << request_id
end
def send_response(response); end
def open; end
def close; end
def handle_request(request); end
end
setup do
configuration = MCP::Configuration.new
configuration.instrumentation_callback = instrumentation_helper.callback
@server = Server.new(name: "test_server", version: "1.0.0", configuration: configuration)
@mock_transport = MockTransport.new(@server)
@server.transport = @mock_transport
@session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "sess-1")
end
test "cooperative cancellation via server_context suppresses response" do
observed_cancelled = false
@server.define_tool(name: "slow") do |server_context:|
20.times do
break if server_context.cancelled?
sleep(0.01)
end
observed_cancelled = server_context.cancelled?
Tool::Response.new([{ type: "text", text: "ok" }])
end
request_id = 42
call_thread = Thread.new do
@session.handle(
jsonrpc: "2.0",
id: request_id,
method: Methods::TOOLS_CALL,
params: { name: "slow", arguments: {} },
)
end
sleep(0.02) until @session.lookup_in_flight(request_id)
@session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "user aborted" },
)
response = call_thread.value
assert observed_cancelled, "tool handler should observe cancellation"
assert_nil response, "cancelled request must not emit a JSON-RPC response"
end
test "tool raising CancelledError via raise_if_cancelled! suppresses the JSON-RPC response" do
@server.define_tool(name: "raising") do |server_context:|
50.times do
server_context.raise_if_cancelled!
sleep(0.01)
end
Tool::Response.new([{ type: "text", text: "ok" }])
end
request_id = 99
call_thread = Thread.new do
@session.handle(
jsonrpc: "2.0",
id: request_id,
method: Methods::TOOLS_CALL,
params: { name: "raising", arguments: {} },
)
end
sleep(0.02) until @session.lookup_in_flight(request_id)
@session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "raised" },
)
response = call_thread.value
assert_nil response, "raise_if_cancelled! must result in no JSON-RPC response"
end
test "CancelledError raised from nested server-to-client call suppresses response" do
@server.define_tool(name: "nesting") do |**|
# Simulate a transport-level CancelledError coming back from a nested
# send_request (e.g. sampling/createMessage after parent was cancelled).
raise MCP::CancelledError.new(request_id: "nested-xyz", reason: "parent aborted")
end
response = @session.handle(
jsonrpc: "2.0",
id: 123,
method: Methods::TOOLS_CALL,
params: { name: "nesting", arguments: {} },
)
assert_nil response, "CancelledError propagating from a nested call must suppress the JSON-RPC response"
end
test "cancellation with unknown request id is silently ignored" do
response = @session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: "does-not-exist" },
)
assert_nil response
end
test "id-bearing notifications/cancelled is rejected with Method not found, not result: null" do
response = @session.handle(
jsonrpc: "2.0",
id: 1001,
method: Methods::NOTIFICATIONS_CANCELLED,
)
assert_equal 1001, response[:id]
refute response.key?(:result)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code)
end
test "id-bearing notifications/initialized is rejected with Method not found" do
response = @session.handle(
jsonrpc: "2.0",
id: 1002,
method: Methods::NOTIFICATIONS_INITIALIZED,
)
assert_equal 1002, response[:id]
refute response.key?(:result)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code)
end
test "id-bearing notifications/progress is rejected with Method not found" do
response = @session.handle(
jsonrpc: "2.0",
id: 1003,
method: Methods::NOTIFICATIONS_PROGRESS,
params: { progressToken: "tok", progress: 1 },
)
assert_equal 1003, response[:id]
refute response.key?(:result)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code)
end
test "well-formed notifications/initialized still receives no response" do
response = @session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_INITIALIZED,
)
assert_nil response
end
test "id-bearing notification mixed into a batch yields only its Method not found error" do
responses = @session.handle([
{ jsonrpc: "2.0", method: Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "x" } },
{ jsonrpc: "2.0", id: 2001, method: Methods::NOTIFICATIONS_CANCELLED },
{ jsonrpc: "2.0", id: 2002, method: Methods::PING },
])
cancelled_error = responses.find { |response| response[:id] == 2001 }
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, cancelled_error.dig(:error, :code)
ping_response = responses.find { |response| response[:id] == 2002 }
assert_equal({}, ping_response[:result])
# The well-formed notification carries no id and contributes no response.
assert_equal 2, responses.size
end
test "id-bearing custom notifications/* method is rejected with Method not found" do
called = false
@server.define_custom_method(method_name: "notifications/custom") { called = true }
response = @session.handle(
jsonrpc: "2.0",
id: 3001,
method: "notifications/custom",
)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code)
refute called
end
test "well-formed custom notifications/* method is dispatched without a response" do
called = false
@server.define_custom_method(method_name: "notifications/custom") { called = true }
response = @session.handle(
jsonrpc: "2.0",
method: "notifications/custom",
)
assert_nil response
assert called
end
test "duplicate cancellation for the same in-flight request is idempotent" do
@server.define_tool(name: "slow_dup") do |server_context:|
20.times do
break if server_context.cancelled?
sleep(0.01)
end
Tool::Response.new([{ type: "text", text: "ok" }])
end
request_id = 51
call_thread = Thread.new do
@session.handle(
jsonrpc: "2.0",
id: request_id,
method: Methods::TOOLS_CALL,
params: { name: "slow_dup", arguments: {} },
)
end
sleep(0.02) until @session.lookup_in_flight(request_id)
first = @session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "first" },
)
second = @session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "second" },
)
response = call_thread.value
assert_nil first
assert_nil second, "duplicate cancel must not emit a response"
assert_nil response, "cancelled request must not emit a response"
end
test "cancellation arriving after the request already completed is silently ignored" do
@server.define_tool(name: "quick") do
Tool::Response.new([{ type: "text", text: "done" }])
end
request_id = 61
response = @session.handle(
jsonrpc: "2.0",
id: request_id,
method: Methods::TOOLS_CALL,
params: { name: "quick", arguments: {} },
)
assert response, "the tool should have completed successfully before the cancel arrives"
assert_nil @session.lookup_in_flight(request_id), "completed request must be unregistered"
late = @session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "late" },
)
assert_nil late, "late cancel for a completed request must be silently ignored"
end
test "initialize request cannot be cancelled" do
init_params = {
protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
clientInfo: { name: "test", version: "1.0" },
capabilities: {},
}
response = @session.handle(
jsonrpc: "2.0",
id: "init-1",
method: Methods::INITIALIZE,
params: init_params,
)
assert response, "initialize returns a response"
assert response[:result][:protocolVersion]
# The initialize request is not registered in the in-flight map.
assert_nil @session.lookup_in_flight("init-1")
end
test "ServerSession#cancel_request sends notification and cancels pending" do
@session.cancel_request(request_id: "req-9", reason: "timeout")
notification = @mock_transport.notifications.last
assert_equal Methods::NOTIFICATIONS_CANCELLED, notification[:method]
assert_equal "req-9", notification[:params][:requestId]
assert_equal "timeout", notification[:params][:reason]
assert_includes @mock_transport.cancelled_request_ids, "req-9"
end
test "parent cancellation propagates to nested server-to-client requests" do
@session.instance_variable_set(:@client_capabilities, { elicitation: {} })
parent_request_id = 101
cancellation = @session.register_in_flight(parent_request_id)
# Simulate a nested sampling/elicitation send via ServerSession. The transport
# is expected to receive `parent_cancellation:` and `server_session:` kwargs so
# it can install an `on_cancel` hook. Here we assert those kwargs are forwarded.
recorded = {}
(class << @mock_transport; self; end).define_method(:send_request) do |method, _params = nil, **kwargs|
recorded[:method] = method
recorded[:kwargs] = kwargs
{}
end
@session.create_form_elicitation(
message: "ignored",
requested_schema: { type: "object", properties: {} },
related_request_id: parent_request_id,
)
assert_equal Methods::ELICITATION_CREATE, recorded[:method]
assert_equal parent_request_id, recorded[:kwargs][:related_request_id]
assert_same cancellation, recorded[:kwargs][:parent_cancellation]
assert_same @session, recorded[:kwargs][:server_session]
end
test "cancellation reason is recorded in instrumentation data" do
recorded_data = nil
@server.configuration.instrumentation_callback = ->(data) { recorded_data = data }
@server.define_tool(name: "slow_with_reason") do |server_context:|
50.times do
break if server_context.cancelled?
sleep(0.01)
end
Tool::Response.new([{ type: "text", text: "ok" }])
end
request_id = 77
call_thread = Thread.new do
@session.handle(
jsonrpc: "2.0",
id: request_id,
method: Methods::TOOLS_CALL,
params: { name: "slow_with_reason", arguments: {} },
)
end
sleep(0.02) until @session.lookup_in_flight(request_id)
@session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "explicit reason string" },
)
call_thread.value
assert recorded_data
assert recorded_data[:cancelled]
assert_equal "explicit reason string", recorded_data[:cancellation_reason]
end
# Helper for the non-tools cancellation regression tests below. Spawns a background
# request, waits for the in-flight entry, sends `notifications/cancelled`, and
# returns [response, thread_result_flag_yielded_by_block].
def drive_cancellation(request_id:, method:, params:)
result_flag = { observed: false }
call_thread = Thread.new do
@session.handle(jsonrpc: "2.0", id: request_id, method: method, params: params)
end
sleep(0.02) until @session.lookup_in_flight(request_id)
yield(result_flag) if block_given?
@session.handle(
jsonrpc: "2.0",
method: Methods::NOTIFICATIONS_CANCELLED,
params: { requestId: request_id, reason: "caller aborted" },
)
[call_thread.value, result_flag]
end
test "resources/read handler that opts in to server_context observes cancellation" do
observed = false
@server.resources_read_handler do |_params, server_context:|
50.times do
break if server_context.cancelled?
sleep(0.01)
end
observed = server_context.cancelled?
[{ uri: "test://resource", text: "done" }]
end
response, = drive_cancellation(
request_id: 201,
method: Methods::RESOURCES_READ,
params: { uri: "test://resource" },
)
assert observed, "resources/read handler should observe cancellation via server_context"
assert_nil response, "cancelled resources/read must not emit a JSON-RPC response"
end
test "completion/complete handler that opts in to server_context observes cancellation" do
observed = false
@server.capabilities[:completions] = {}
@server.completion_handler do |_params, server_context:|
50.times do
break if server_context.cancelled?
sleep(0.01)
end
observed = server_context.cancelled?
{ completion: { values: ["v"], hasMore: false } }
end
# completion/complete requires a known ref; register a dummy prompt.
@server.define_prompt(name: "dummy") { MCP::Prompt::Result.new(messages: []) }
response, = drive_cancellation(
request_id: 202,
method: Methods::COMPLETION_COMPLETE,
params: { ref: { type: "ref/prompt", name: "dummy" }, argument: { name: "arg", value: "" } },
)
assert observed, "completion/complete handler should observe cancellation via server_context"
assert_nil response, "cancelled completion/complete must not emit a JSON-RPC response"
end
test "prompts/get template that opts in to server_context observes cancellation" do
observed = false
prompt_class = Class.new(MCP::Prompt) do
prompt_name "slow_prompt"
define_singleton_method(:template) do |_args, server_context:|
50.times do
break if server_context.cancelled?
sleep(0.01)
end
observed = server_context.cancelled?
MCP::Prompt::Result.new(messages: [])
end
end
@server.prompts[prompt_class.name_value] = prompt_class
# Share `observed` between the closure above and the test scope.
@server.singleton_class.define_method(:_test_observed) { observed }
response, = drive_cancellation(
request_id: 203,
method: Methods::PROMPTS_GET,
params: { name: "slow_prompt" },
)
assert observed, "prompt template should observe cancellation via server_context"
assert_nil response, "cancelled prompts/get must not emit a JSON-RPC response"
end
test "send_to_transport (notifications) works with a custom transport that only implements the abstract signature" do
# Regression: `ServerSession#send_to_transport` must not assume the
# transport's `send_notification` accepts the new kwargs
# (`session_id:` / `related_request_id:`). Custom transports that
# implement the abstract `Transport#send_notification(method, params = nil)`
# contract must keep working - this is exercised via `cancel_request` /
# `send_peer_cancellation` which the cancellation feature relies on.
minimal_transport = Class.new(Transport) do
attr_reader :recorded
def initialize(server)
super
@recorded = []
end
def send_notification(method, params = nil)
@recorded << [method, params]
true
end
def send_request(method, params = nil)
@recorded << [method, params]
{}
end
def send_response(_); end
def open; end
def close; end
end.new(@server)
session = ServerSession.new(server: @server, transport: minimal_transport)
assert_nothing_raised do
session.cancel_request(request_id: "req-1", reason: "timeout")
end
recorded = minimal_transport.recorded.last
assert_equal Methods::NOTIFICATIONS_CANCELLED, recorded[0]
assert_equal "req-1", recorded[1][:requestId]
assert_equal "timeout", recorded[1][:reason]
end
test "send_to_transport_request works with a custom transport that only implements the abstract signature" do
# Regression: `ServerSession#send_to_transport_request` must not assume
# the transport's `send_request` accepts the new kwargs
# (`session_id:` / `related_request_id:` / `parent_cancellation:` /
# `server_session:`). Custom transports that implement the abstract
# `Transport#send_request(method, params = nil)` contract must keep working.
minimal_transport = Class.new(Transport) do
attr_reader :recorded
def initialize(server)
super
@recorded = []
end
def send_request(method, params = nil)
@recorded << [method, params]
{} # fake response
end
def send_notification(method, params = nil)
@recorded << [method, params]
true
end
def send_response(_); end
def open; end
def close; end
end.new(@server)
session = ServerSession.new(server: @server, transport: minimal_transport)
session.instance_variable_set(:@client_capabilities, { sampling: {} })
assert_nothing_raised do
session.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "hi" } }],
max_tokens: 10,
)
end
recorded = minimal_transport.recorded.last
assert_equal Methods::SAMPLING_CREATE_MESSAGE, recorded[0]
refute_nil recorded[1]
end
test "tool with positional `server_context` parameter is not auto-opted-in" do
# Regression: `accepts_server_context?` (used by tool/prompt dispatch) must
# require `server_context` as a *keyword* parameter. A tool whose `call`
# signature is `def self.call(arg, server_context)` (positional name collision)
# would previously have been opt-in, and `tool.call(**args, server_context: ctx)`
# would have blown up or passed the wrapped Hash to the wrong slot.
tool = Class.new(MCP::Tool) do
tool_name "positional_collision"
input_schema(properties: { a: { type: "string" } }, required: ["a"])
class << self
def call(a, server_context)
[a, server_context]
end
end
end
@server.tools[tool.name_value] = tool
# Should dispatch without `server_context:` kwarg - the positional arg collision
# must NOT trigger opt-in. The tool receives only its declared positional (a).
response = @session.handle(
jsonrpc: "2.0",
id: 303,
method: Methods::TOOLS_CALL,
params: { name: "positional_collision", arguments: { a: "hello" } },
)
# Because the tool signature isn't opt-in, `tool.call(**args)` is called
# (without `server_context:`). The method has `server_context` as a required
# positional, so missing it raises ArgumentError - caught as an internal error.
# The key assertion is that we do NOT pass `server_context:` kwarg that would
# flow into the positional slot as a Hash.
assert response.dig(:error), "dispatch should not silently inject server_context kwarg"
end
test "handler with positional `server_context` parameter is not auto-opted-in" do
# Regression: `handler_declares_server_context?` must require `server_context`
# as a *keyword* parameter. A positional parameter that happens to be named
# `server_context` (rare but possible) would previously have been treated as
# opt-in, and the dispatch site would call `handler.call(params, server_context: ctx)`
# - the second positional argument would become the Hash `{server_context: ctx}`,
# which is never what the user intended.
received_args = nil
@server.define_custom_method(method_name: "custom/positional_name") do |params, server_context|
received_args = [params, server_context]
{ ok: true }
end
response = @session.handle(
jsonrpc: "2.0",
id: 302,
method: "custom/positional_name",
params: { hello: "world" },
)
assert_equal [{ hello: "world" }, nil],
received_args,
"positional `server_context` must receive the single `params` argument, not an auto-wrapped context"
assert_equal({ ok: true }, response[:result])
end
test "handler with **kwargs-only signature is not auto-opted-in to server_context" do
# Regression: a block like `|**opts|` would have triggered opt-in under the
# looser `accepts_server_context?` check used by tools, and the dispatch site
# would then call `handler.call(params, server_context:)` - but a proc that
# only declares `**opts` cannot accept the positional `params` (lambdas raise
# ArgumentError, non-lambda procs silently drop it). The stricter
# `handler_declares_server_context?` check requires `server_context` as a
# named keyword to opt in, so a kwargs-only handler receives only `params`.
received_positional = nil
@server.define_custom_method(method_name: "custom/kwargs_only") do |params, **_opts|
received_positional = params
{ ok: true }
end
response = @session.handle(
jsonrpc: "2.0",
id: 301,
method: "custom/kwargs_only",
params: { hello: "world" },
)
assert_equal({ hello: "world" }, received_positional)
assert_equal({ ok: true }, response[:result])
end
test "custom method handler that opts in to server_context observes cancellation" do
observed = false
@server.define_custom_method(method_name: "custom/slow") do |_params, server_context:|
50.times do
break if server_context.cancelled?
sleep(0.01)
end
observed = server_context.cancelled?
{ ok: true }
end
response, = drive_cancellation(
request_id: 204,
method: "custom/slow",
params: {},
)
assert observed, "custom method handler should observe cancellation via server_context"
assert_nil response, "cancelled custom method must not emit a JSON-RPC response"
end
test "send_peer_cancellation routes notification on parent stream via related_request_id" do
recorded_notifications = []
(class << @mock_transport; self; end).define_method(:send_notification) do |method, params = nil, **kwargs|
recorded_notifications << { method: method, params: params, kwargs: kwargs }
true
end
@session.send_peer_cancellation(
nested_request_id: "nested-1",
related_request_id: 42,
reason: "parent aborted",
)
notif = recorded_notifications.last
assert_equal Methods::NOTIFICATIONS_CANCELLED, notif[:method]
assert_equal "nested-1", notif[:params][:requestId]
assert_equal "parent aborted", notif[:params][:reason]
# Crucially, the cancel notification targets the parent's stream, not the GET SSE stream.
assert_equal 42, notif[:kwargs][:related_request_id]
end
end
end