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
5 changed files with 323 additions and 16 deletions

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(

View file

@ -57,6 +57,87 @@ module MCP
assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots]
end
test "ServerContext#list_roots stamps the originating request id" do
# Per SEP-2260, server-to-client requests are associated with the originating client request automatically.
notification_target = mock
notification_target.expects(:list_roots).with(related_request_id: "req-1").returns({ roots: [] })
progress = Progress.new(notification_target: notification_target, progress_token: nil)
server_context = ServerContext.new(
mock,
progress: progress,
notification_target: notification_target,
related_request_id: "req-1",
)
server_context.list_roots
end
test "ServerContext#create_sampling_message stamps related_request_id non-overridably" do
# A caller-supplied `related_request_id:` must not detach the request from its originating client request (SEP-2260):
# the literal keyword after `**kwargs` wins.
notification_target = mock
notification_target.expects(:create_sampling_message).with(
messages: [],
max_tokens: 5,
related_request_id: "req-1",
).returns({})
progress = Progress.new(notification_target: notification_target, progress_token: nil)
server_context = ServerContext.new(
mock,
progress: progress,
notification_target: notification_target,
related_request_id: "req-1",
)
server_context.create_sampling_message(messages: [], max_tokens: 5, related_request_id: "attacker")
end
test "ServerContext#create_form_elicitation stamps related_request_id non-overridably" do
notification_target = mock
notification_target.expects(:create_form_elicitation).with(
message: "hi",
requested_schema: {},
related_request_id: "req-1",
).returns({})
progress = Progress.new(notification_target: notification_target, progress_token: nil)
server_context = ServerContext.new(
mock,
progress: progress,
notification_target: notification_target,
related_request_id: "req-1",
)
server_context.create_form_elicitation(message: "hi", requested_schema: {}, related_request_id: "attacker")
end
test "ServerContext#create_url_elicitation stamps related_request_id non-overridably" do
notification_target = mock
notification_target.expects(:create_url_elicitation).with(
message: "hi",
url: "https://example.com",
elicitation_id: "el-1",
related_request_id: "req-1",
).returns({})
progress = Progress.new(notification_target: notification_target, progress_token: nil)
server_context = ServerContext.new(
mock,
progress: progress,
notification_target: notification_target,
related_request_id: "req-1",
)
server_context.create_url_elicitation(
message: "hi",
url: "https://example.com",
elicitation_id: "el-1",
related_request_id: "attacker",
)
end
test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do
notification_target = mock
context = mock

View file

@ -48,8 +48,52 @@ module MCP
@session.instance_variable_set(:@client_capabilities, { elicitation: {} })
end
test "#create_form_elicitation warns when called without related_request_id" do
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
original_verbose = $VERBOSE
$VERBOSE = false
assert_output(nil, /SEP-2260/) do
@session.create_form_elicitation(
message: "Please provide your name",
requested_schema: { type: "object", properties: { name: { type: "string" } } },
)
end
ensure
$VERBOSE = original_verbose
end
test "#create_url_elicitation warns when called without related_request_id" do
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: true } })
original_verbose = $VERBOSE
$VERBOSE = false
assert_output(nil, /SEP-2260/) do
@session.create_url_elicitation(
message: "Please sign in",
url: "https://example.com/signin",
elicitation_id: "el-1",
)
end
ensure
$VERBOSE = original_verbose
end
test "#create_form_elicitation does not warn when related_request_id is given" do
assert_silent do
@session.create_form_elicitation(
related_request_id: "req-1",
message: "Please provide your name",
requested_schema: { type: "object", properties: { name: { type: "string" } } },
)
end
end
test "#create_form_elicitation sends request through transport" do
result = @session.create_form_elicitation(
related_request_id: "req-1",
message: "Please provide your name",
requested_schema: {
type: "object",
@ -76,6 +120,7 @@ module MCP
error = assert_raises(RuntimeError) do
@session.create_form_elicitation(
related_request_id: "req-1",
message: "Please provide your name",
requested_schema: { type: "object", properties: { name: { type: "string" } } },
)
@ -93,6 +138,7 @@ module MCP
error = assert_raises(RuntimeError) do
@session.create_form_elicitation(
related_request_id: "req-1",
message: "Please provide your name",
requested_schema: { type: "object", properties: { name: { type: "string" } } },
)
@ -108,6 +154,7 @@ module MCP
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } })
result = @session.create_url_elicitation(
related_request_id: "req-1",
message: "Please authorize access",
url: "https://example.com/oauth",
elicitation_id: "abc-123",
@ -128,6 +175,7 @@ module MCP
test "#create_url_elicitation raises error when client does not support url mode" do
error = assert_raises(RuntimeError) do
@session.create_url_elicitation(
related_request_id: "req-1",
message: "Please authorize access",
url: "https://example.com/oauth",
elicitation_id: "abc-123",
@ -146,6 +194,7 @@ module MCP
error = assert_raises(RuntimeError) do
@session.create_url_elicitation(
related_request_id: "req-1",
message: "Please authorize access",
url: "https://example.com/oauth",
elicitation_id: "abc-123",

View file

@ -111,7 +111,7 @@ module MCP
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_roots }
error_with_roots = assert_raises(RuntimeError) do
session_with_roots.list_roots
capture_io { session_with_roots.list_roots }
end
assert_equal("No active stream for roots/list request.", error_with_roots.message)
@ -119,11 +119,31 @@ module MCP
session_without_roots.store_client_info(client: { name: "incapable" }, capabilities: {})
error = assert_raises(RuntimeError) do
session_without_roots.list_roots
capture_io { session_without_roots.list_roots }
end
assert_equal("Client does not support roots.", error.message)
end
test "ServerSession#list_roots warns when called without related_request_id" do
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
session = ServerSession.new(server: @server, transport: @mock_transport)
session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} })
original_verbose = $VERBOSE
$VERBOSE = false
assert_output(nil, /SEP-2260/) { session.list_roots }
ensure
$VERBOSE = original_verbose
end
test "ServerSession#list_roots does not warn when related_request_id is given" do
session = ServerSession.new(server: @server, transport: @mock_transport)
session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} })
assert_silent { session.list_roots(related_request_id: "req-1") }
end
test "ServerSession#client_capabilities falls back to server global capabilities" do
transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
@ -131,7 +151,7 @@ module MCP
transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session }
error = assert_raises(RuntimeError) do
session.list_roots
capture_io { session.list_roots }
end
assert_equal("No active stream for roots/list request.", error.message)
end
@ -195,7 +215,7 @@ module MCP
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session }
error = assert_raises(RuntimeError) do
session.list_roots
capture_io { session.list_roots }
end
assert_equal("No active stream for roots/list request.", error.message)
end

View file

@ -48,6 +48,7 @@ module MCP
test "create_sampling_message sends request with required params" do
result = @session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
@ -64,6 +65,7 @@ module MCP
test "create_sampling_message sends all optional params" do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
system_prompt: "You are helpful",
@ -90,6 +92,7 @@ module MCP
error = assert_raises(RuntimeError) do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
@ -101,6 +104,7 @@ module MCP
test "create_sampling_message raises error when tools used but client lacks sampling.tools" do
error = assert_raises(RuntimeError) do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
tools: [{ name: "test_tool", inputSchema: { type: "object" } }],
@ -113,6 +117,7 @@ module MCP
test "create_sampling_message raises error when tool_choice used alone but client lacks sampling.tools" do
error = assert_raises(RuntimeError) do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
tool_choice: { mode: "auto" },
@ -126,6 +131,7 @@ module MCP
@session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: { tools: {} } })
result = @session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
tools: [{ name: "test_tool", inputSchema: { type: "object" } }],
@ -140,6 +146,31 @@ module MCP
assert_equal "Response from LLM", result[:content][:text]
end
test "create_sampling_message warns when called without related_request_id" do
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
original_verbose = $VERBOSE
$VERBOSE = false
assert_output(nil, /SEP-2260/) do
@session.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
end
ensure
$VERBOSE = original_verbose
end
test "create_sampling_message does not warn when related_request_id is given" do
assert_silent do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
end
end
test "create_sampling_message uses per-session capabilities via ServerSession" do
transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
@ -149,10 +180,12 @@ module MCP
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_sampling }
error_with_sampling = assert_raises(RuntimeError) do
session_with_sampling.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
capture_io do
session_with_sampling.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
end
end
assert_equal("No active stream for sampling/createMessage request.", error_with_sampling.message)
@ -161,10 +194,12 @@ module MCP
session_without_sampling.store_client_info(client: { name: "incapable" }, capabilities: {})
error = assert_raises(RuntimeError) do
session_without_sampling.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
capture_io do
session_without_sampling.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
end
end
assert_equal("Client does not support sampling.", error.message)
end
@ -190,10 +225,12 @@ module MCP
# Server was initialized with sampling capability, so fallback should pass validation.
error = assert_raises(RuntimeError) do
session.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
capture_io do
session.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
)
end
end
assert_equal("No active stream for sampling/createMessage request.", error.message)
end
@ -243,6 +280,7 @@ module MCP
test "create_sampling_message omits nil optional params" do
@session.create_sampling_message(
related_request_id: "req-1",
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
max_tokens: 100,
system_prompt: nil,