Associate server-to-client requests with the originating client request per SEP-2260

## Motivation and Context

SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.

The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):

- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
  emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
  on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
  deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
  and never falls back to the GET stream.

Resolves #381.

## How Has This Been Tested?

- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
  `create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
  "attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
  each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
  The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
  the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
  is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
  stays clean; their assertions are unchanged.

## Breaking Changes

None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
This commit is contained in:
Koichi ITO 2026-06-19 03:10:54 +09:00
commit 6ddcc49865

View file

@ -2891,6 +2891,67 @@ module MCP
assert_equal "Hi there", result[:content][:text]
end
test "send_request with related_request_id rides the originating POST stream, not the GET stream" do
# Per SEP-2260, server-to-client requests associated with a client request are delivered on
# that request's POST SSE response stream rather than the standalone GET stream.
init_request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json" },
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
)
init_response = @transport.handle_request(init_request)
session_id = init_response[1]["Mcp-Session-Id"]
# Connect GET SSE.
get_io = StringIO.new
get_request = create_rack_request(
"GET",
"/",
{ "HTTP_MCP_SESSION_ID" => session_id },
)
response = @transport.handle_request(get_request)
response[2].call(get_io) if response[2].is_a?(Proc)
sleep(0.1)
# Register an in-flight POST request stream for "req-1".
post_io = StringIO.new
@transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { "req-1" => post_io }
result_queue = Queue.new
Thread.new do
result = @transport.send_request(
"roots/list",
nil,
session_id: session_id,
related_request_id: "req-1",
)
result_queue.push(result)
end
sleep(0.1)
post_io.rewind
post_output = post_io.read
assert_includes post_output, "roots/list"
get_io.rewind
refute_includes get_io.read, "roots/list"
# Respond so the background send_request completes.
data_lines = post_output.lines.select { |line| line.start_with?("data: ") }
request_id = JSON.parse(data_lines.first.sub("data: ", ""))["id"]
client_response = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
{ jsonrpc: "2.0", id: request_id, result: { roots: [] } }.to_json,
)
@transport.handle_request(client_response)
assert_equal({ roots: [] }, result_queue.pop)
end
test "send_request ignores response from wrong session" do
# Create two sessions.
init_a = create_rack_request(