ruby-sdk/test/mcp/server_cancellation_test.rb
Koichi ITO a53e8d9ed4 Reject duplicate in-flight JSON-RPC request IDs
## Motivation and Context

A request id is the only key that routes request-scoped messages back to the request that caused them:
progress and log notifications, server-to-client requests such as sampling and elicitation,
and `notifications/cancelled` all resolve through it. Nothing checked that the id was free,
so a second request arriving under an id already in flight took the routing entry over.
From that point the first request's notifications were written to the second request's SSE stream,
and whichever finished first removed the shared entry, leaving the survivor's messages nowhere to go.

The spec puts the uniqueness obligation on the sender, and 2026-07-28 draws it at exactly
the window this change enforces: "The request ID MUST NOT match the ID of any other request the sender
has issued and not yet received a response for". Earlier revisions worded it as never reusing an id within
the session. Neither says what a receiver does with a duplicate, but the Streamable HTTP rules require
that messages the server sends before the response "SHOULD relate to the originating client request",
and two live requests sharing one id make that impossible to honor for either of them.
Answering the second one is the only option that stays correct, so it is refused the same way
a duplicate `initialize` is, and for the same reason: a repeated id must not silently displace state
the first one established.

The reference SDKs currently accept the duplicate and let the newer request take the entry.
That is not a settled design: the Python SDK carries a TODO naming rejection with `INVALID_REQUEST`
as the revisit, so this moves toward where they expect to end up rather than away from them.

`ServerSession#register_in_flight` now claims the id atomically and reports a collision instead of overwriting,
which `Server` turns into an Invalid Request for every transport. `StreamableHTTPTransport` refuses
the colliding POST with 409 before it registers a stream, since the stream is registered ahead of dispatch
and would otherwise take over the routing entry for as long as the rejection takes.

Removal is now guarded by identity everywhere the registry is keyed by request id, so a request that loses
a race cannot retire a registration it does not own. `initialize` never registers, so its `ensure` no longer unregisters:
a refused duplicate `initialize` reusing an in-flight id used to evict that registration on the way out,
silently disabling cancellation for the request that owned it (Streamable HTTP refuses such a POST before dispatch;
stdio reached this path). `drop_broken_stream` had a sharper form of the same bug: it removed whichever stream held
the id while closing the one passed in, which are not necessarily the same stream.

Only ids that are in flight are refused, which is the scope the current spec draws.
Enforcing the older "never within the session" wording would instead mean remembering every id a session ever used,
and the in-flight window is the part routing depends on either way. Sequential reuse keeps working,
and neither this SDK's client (`SecureRandom.uuid`) nor a client built on the TypeScript SDK (a per-connection counter)
can produce a collision.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` reproduce the collision:
a tool parked mid-request, a second POST reusing its id, and assertions that the second POST is refused
and that the first request still receives the progress frame it emits afterwards. Others cover
the identity-guarded removal and confirm that an id can be reused once the earlier request has finished.
New tests in `test/mcp/server_cancellation_test.rb` cover the registry directly, including
the transport-independent Invalid Request and the survival of an in-flight registration across
a refused duplicate `initialize` under the same id. All of them except the sequential-reuse guard fail
without this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

A request whose id is already in flight on the same session is now answered with Invalid Request
(HTTP 409 on Streamable HTTP) instead of being processed. A client that reuses ids concurrently was
already violating the specification and was already having its notifications misrouted.
2026-08-17 03:47:35 +09:00

786 lines
27 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class ServerCancellationTest < ActiveSupport::TestCase
include InstrumentationTestHelper
include InitializeParamsTestHelper
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 "register_in_flight refuses an id that is already in flight" do
first = @session.register_in_flight("req-1")
assert first
assert_nil @session.register_in_flight("req-1"), "a second live request cannot share the id"
assert_same first, @session.lookup_in_flight("req-1"), "the first registration must survive"
end
test "unregister_in_flight leaves a registration it does not own" do
owner = @session.register_in_flight("req-1")
@session.unregister_in_flight("req-1", cancellation: Cancellation.new(request_id: "req-1"))
assert_same owner, @session.lookup_in_flight("req-1")
@session.unregister_in_flight("req-1", cancellation: owner)
assert_nil @session.lookup_in_flight("req-1")
end
test "in_flight? reports whether an id is registered" do
refute @session.in_flight?("req-1")
@session.register_in_flight("req-1")
assert @session.in_flight?("req-1")
end
test "a request reusing an in-flight id is answered with Invalid Request" do
@server.define_tool(name: "slow") do |server_context:|
sleep(0.2)
Tool::Response.new([{ type: "text", text: "ok" }])
end
request = {
jsonrpc: "2.0",
id: "req-1",
method: Methods::TOOLS_CALL,
params: { name: "slow", arguments: {} },
}
in_flight = Thread.new { @session.handle(request) }
sleep(0.01) until @session.lookup_in_flight("req-1")
duplicate = @session.handle(request)
assert_equal(
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
duplicate.dig(:error, :code) || duplicate.dig("error", "code"),
)
in_flight.join
end
test "a refused duplicate initialize leaves an in-flight registration under its reused id" do
@session.handle(jsonrpc: "2.0", id: "init", method: Methods::INITIALIZE, params: initialize_params)
owner = @session.register_in_flight("req-1")
# `initialize` bypasses the duplicate-id refusal (it is never in flight itself), so its
# rejection path is the one place a reused id reaches a handler while the id is still live.
duplicate = @session.handle(jsonrpc: "2.0", id: "req-1", method: Methods::INITIALIZE, params: initialize_params)
assert_equal(
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
duplicate.dig(:error, :code) || duplicate.dig("error", "code"),
)
assert_same owner, @session.lookup_in_flight("req-1"), "the registration must survive the refused initialize"
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