## 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.
786 lines
27 KiB
Ruby
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
|