Skip to content

Commit 9b39157

Browse files
committed
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 modelcontextprotocol#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.
1 parent 8b1a41a commit 9b39157

8 files changed

Lines changed: 319 additions & 16 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,12 @@ Roots define the boundaries of where a server can operate, providing a list of d
10231023
- **Client Capability**: Clients must declare `roots` capability during initialization
10241024
- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change
10251025

1026+
> [!NOTE]
1027+
> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with
1028+
> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association
1029+
> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding
1030+
> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning.
1031+
10261032
**Using Roots in Tools:**
10271033

10281034
Tools that accept a `server_context:` parameter can call `list_roots` on it.

lib/mcp/server_context.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ def notify_resources_updated(uri:)
5454
end
5555

5656
# Delegates to the session so the request is scoped to the originating client.
57+
# The originating request id is stamped as `related_request_id`, satisfying
58+
# SEP-2260's requirement that server-to-client requests be associated with
59+
# the client request being processed.
5760
# @deprecated MCP Roots (`roots/list` and
5861
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
5962
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
@@ -91,6 +94,11 @@ def ping
9194
# Delegates to the session so the request is scoped to the originating client.
9295
# Falls back to `@context` (via `method_missing`) when `@notification_target`
9396
# does not support sampling.
97+
#
98+
# The originating request id is stamped as `related_request_id` and cannot be
99+
# overridden by callers (the literal keyword after `**kwargs` wins),
100+
# satisfying SEP-2260's requirement that server-to-client requests be
101+
# associated with the client request being processed.
94102
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
95103
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
96104
# APIs instead.
@@ -107,6 +115,8 @@ def create_sampling_message(**kwargs)
107115
# Delegates to the session so the request is scoped to the originating client.
108116
# Falls back to `@context` (via `method_missing`) when `@notification_target`
109117
# does not support elicitation.
118+
# The originating request id is stamped as a non-overridable
119+
# `related_request_id`, as with `create_sampling_message` (SEP-2260).
110120
def create_form_elicitation(**kwargs)
111121
if @notification_target.respond_to?(:create_form_elicitation)
112122
@notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
@@ -119,6 +129,8 @@ def create_form_elicitation(**kwargs)
119129

120130
# Delegates to the session so the request is scoped to the originating client.
121131
# Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
132+
# The originating request id is stamped as a non-overridable `related_request_id`,
133+
# as with `create_sampling_message` (SEP-2260).
122134
def create_url_elicitation(**kwargs)
123135
if @notification_target.respond_to?(:create_url_elicitation)
124136
@notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)

lib/mcp/server_session.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,16 @@ def client_capabilities
9898
end
9999

100100
# Sends a `roots/list` request scoped to this session.
101+
#
102+
# Per SEP-2260, the request must be associated with an originating client
103+
# request; prefer `server_context.list_roots` inside a handler, which stamps
104+
# the association automatically.
101105
# @deprecated MCP Roots (`roots/list` and
102106
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
103107
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
104108
# server configuration, or environment variables instead.
105109
def list_roots(related_request_id: nil)
110+
warn_unassociated_request(__method__, related_request_id)
106111
unless client_capabilities&.dig(:roots)
107112
raise "Client does not support roots."
108113
end
@@ -119,16 +124,26 @@ def ping(related_request_id: nil)
119124
end
120125

121126
# Sends a `sampling/createMessage` request scoped to this session.
127+
#
128+
# Per SEP-2260, the request must be associated with an originating client
129+
# request; prefer `server_context.create_sampling_message` inside a handler,
130+
# which stamps the association automatically.
122131
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
123132
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
124133
# APIs instead.
125134
def create_sampling_message(related_request_id: nil, **kwargs)
135+
warn_unassociated_request(__method__, related_request_id)
126136
params = @server.build_sampling_params(client_capabilities, **kwargs)
127137
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
128138
end
129139

130140
# Sends an `elicitation/create` request (form mode) scoped to this session.
141+
#
142+
# Per SEP-2260, the request must be associated with an originating client
143+
# request; prefer `server_context.create_form_elicitation` inside a handler,
144+
# which stamps the association automatically.
131145
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
146+
warn_unassociated_request(__method__, related_request_id)
132147
unless client_capabilities&.dig(:elicitation)
133148
raise "Client does not support elicitation. " \
134149
"The client must declare the `elicitation` capability during initialization."
@@ -139,7 +154,12 @@ def create_form_elicitation(message:, requested_schema:, related_request_id: nil
139154
end
140155

141156
# Sends an `elicitation/create` request (URL mode) scoped to this session.
157+
#
158+
# Per SEP-2260, the request must be associated with an originating client
159+
# request; prefer `server_context.create_url_elicitation` inside a handler,
160+
# which stamps the association automatically.
142161
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
162+
warn_unassociated_request(__method__, related_request_id)
143163
unless client_capabilities&.dig(:elicitation, :url)
144164
raise "Client does not support URL mode elicitation. " \
145165
"The client must declare the `elicitation.url` capability during initialization."
@@ -212,6 +232,22 @@ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
212232

213233
private
214234

235+
# Per SEP-2260, servers MUST send `roots/list`, `sampling/createMessage`, and `elicitation/create` only
236+
# in association with an originating client request (`ping` is exempt). A request without `related_request_id:` is
237+
# delivered on the standalone GET stream by the Streamable HTTP transport, which the 2026-07-28 spec forbids;
238+
# warn so callers migrate to the `server_context` helpers before this becomes an error.
239+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260
240+
def warn_unassociated_request(method_name, related_request_id)
241+
return if related_request_id
242+
243+
warn(
244+
"Calling `ServerSession##{method_name}` without `related_request_id:` is deprecated (SEP-2260): " \
245+
"server-to-client #{method_name} requests must be associated with an originating client request. " \
246+
"Use `server_context.#{method_name}` inside a handler, or pass `related_request_id:` explicitly.",
247+
uplevel: 2,
248+
)
249+
end
250+
215251
# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
216252
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
217253
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2801,6 +2801,67 @@ def string
28012801
assert_equal "Hi there", result[:content][:text]
28022802
end
28032803

2804+
test "send_request with related_request_id rides the originating POST stream, not the GET stream" do
2805+
# Per SEP-2260, server-to-client requests associated with a client request are delivered on
2806+
# that request's POST SSE response stream rather than the standalone GET stream.
2807+
init_request = create_rack_request(
2808+
"POST",
2809+
"/",
2810+
{ "CONTENT_TYPE" => "application/json" },
2811+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
2812+
)
2813+
init_response = @transport.handle_request(init_request)
2814+
session_id = init_response[1]["Mcp-Session-Id"]
2815+
2816+
# Connect GET SSE.
2817+
get_io = StringIO.new
2818+
get_request = create_rack_request(
2819+
"GET",
2820+
"/",
2821+
{ "HTTP_MCP_SESSION_ID" => session_id },
2822+
)
2823+
response = @transport.handle_request(get_request)
2824+
response[2].call(get_io) if response[2].is_a?(Proc)
2825+
sleep(0.1)
2826+
2827+
# Register an in-flight POST request stream for "req-1".
2828+
post_io = StringIO.new
2829+
@transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { "req-1" => post_io }
2830+
2831+
result_queue = Queue.new
2832+
Thread.new do
2833+
result = @transport.send_request(
2834+
"roots/list",
2835+
nil,
2836+
session_id: session_id,
2837+
related_request_id: "req-1",
2838+
)
2839+
result_queue.push(result)
2840+
end
2841+
2842+
sleep(0.1)
2843+
2844+
post_io.rewind
2845+
post_output = post_io.read
2846+
assert_includes post_output, "roots/list"
2847+
2848+
get_io.rewind
2849+
refute_includes get_io.read, "roots/list"
2850+
2851+
# Respond so the background send_request completes.
2852+
data_lines = post_output.lines.select { |line| line.start_with?("data: ") }
2853+
request_id = JSON.parse(data_lines.first.sub("data: ", ""))["id"]
2854+
client_response = create_rack_request(
2855+
"POST",
2856+
"/",
2857+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
2858+
{ jsonrpc: "2.0", id: request_id, result: { roots: [] } }.to_json,
2859+
)
2860+
@transport.handle_request(client_response)
2861+
2862+
assert_equal({ roots: [] }, result_queue.pop)
2863+
end
2864+
28042865
test "send_request ignores response from wrong session" do
28052866
# Create two sessions.
28062867
init_a = create_rack_request(

test/mcp/server_context_test.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,87 @@ class ServerContextTest < ActiveSupport::TestCase
5757
assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots]
5858
end
5959

60+
test "ServerContext#list_roots stamps the originating request id" do
61+
# Per SEP-2260, server-to-client requests are associated with the originating client request automatically.
62+
notification_target = mock
63+
notification_target.expects(:list_roots).with(related_request_id: "req-1").returns({ roots: [] })
64+
65+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
66+
server_context = ServerContext.new(
67+
mock,
68+
progress: progress,
69+
notification_target: notification_target,
70+
related_request_id: "req-1",
71+
)
72+
73+
server_context.list_roots
74+
end
75+
76+
test "ServerContext#create_sampling_message stamps related_request_id non-overridably" do
77+
# A caller-supplied `related_request_id:` must not detach the request from its originating client request (SEP-2260):
78+
# the literal keyword after `**kwargs` wins.
79+
notification_target = mock
80+
notification_target.expects(:create_sampling_message).with(
81+
messages: [],
82+
max_tokens: 5,
83+
related_request_id: "req-1",
84+
).returns({})
85+
86+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
87+
server_context = ServerContext.new(
88+
mock,
89+
progress: progress,
90+
notification_target: notification_target,
91+
related_request_id: "req-1",
92+
)
93+
94+
server_context.create_sampling_message(messages: [], max_tokens: 5, related_request_id: "attacker")
95+
end
96+
97+
test "ServerContext#create_form_elicitation stamps related_request_id non-overridably" do
98+
notification_target = mock
99+
notification_target.expects(:create_form_elicitation).with(
100+
message: "hi",
101+
requested_schema: {},
102+
related_request_id: "req-1",
103+
).returns({})
104+
105+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
106+
server_context = ServerContext.new(
107+
mock,
108+
progress: progress,
109+
notification_target: notification_target,
110+
related_request_id: "req-1",
111+
)
112+
113+
server_context.create_form_elicitation(message: "hi", requested_schema: {}, related_request_id: "attacker")
114+
end
115+
116+
test "ServerContext#create_url_elicitation stamps related_request_id non-overridably" do
117+
notification_target = mock
118+
notification_target.expects(:create_url_elicitation).with(
119+
message: "hi",
120+
url: "https://example.com",
121+
elicitation_id: "el-1",
122+
related_request_id: "req-1",
123+
).returns({})
124+
125+
progress = Progress.new(notification_target: notification_target, progress_token: nil)
126+
server_context = ServerContext.new(
127+
mock,
128+
progress: progress,
129+
notification_target: notification_target,
130+
related_request_id: "req-1",
131+
)
132+
133+
server_context.create_url_elicitation(
134+
message: "hi",
135+
url: "https://example.com",
136+
elicitation_id: "el-1",
137+
related_request_id: "attacker",
138+
)
139+
end
140+
60141
test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do
61142
notification_target = mock
62143
context = mock

test/mcp/server_elicitation_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,52 @@ def handle_request(request); end
4848
@session.instance_variable_set(:@client_capabilities, { elicitation: {} })
4949
end
5050

51+
test "#create_form_elicitation warns when called without related_request_id" do
52+
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
53+
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
54+
original_verbose = $VERBOSE
55+
$VERBOSE = false
56+
57+
assert_output(nil, /SEP-2260/) do
58+
@session.create_form_elicitation(
59+
message: "Please provide your name",
60+
requested_schema: { type: "object", properties: { name: { type: "string" } } },
61+
)
62+
end
63+
ensure
64+
$VERBOSE = original_verbose
65+
end
66+
67+
test "#create_url_elicitation warns when called without related_request_id" do
68+
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: true } })
69+
70+
original_verbose = $VERBOSE
71+
$VERBOSE = false
72+
73+
assert_output(nil, /SEP-2260/) do
74+
@session.create_url_elicitation(
75+
message: "Please sign in",
76+
url: "https://example.com/signin",
77+
elicitation_id: "el-1",
78+
)
79+
end
80+
ensure
81+
$VERBOSE = original_verbose
82+
end
83+
84+
test "#create_form_elicitation does not warn when related_request_id is given" do
85+
assert_silent do
86+
@session.create_form_elicitation(
87+
related_request_id: "req-1",
88+
message: "Please provide your name",
89+
requested_schema: { type: "object", properties: { name: { type: "string" } } },
90+
)
91+
end
92+
end
93+
5194
test "#create_form_elicitation sends request through transport" do
5295
result = @session.create_form_elicitation(
96+
related_request_id: "req-1",
5397
message: "Please provide your name",
5498
requested_schema: {
5599
type: "object",
@@ -76,6 +120,7 @@ def handle_request(request); end
76120

77121
error = assert_raises(RuntimeError) do
78122
@session.create_form_elicitation(
123+
related_request_id: "req-1",
79124
message: "Please provide your name",
80125
requested_schema: { type: "object", properties: { name: { type: "string" } } },
81126
)
@@ -93,6 +138,7 @@ def handle_request(request); end
93138

94139
error = assert_raises(RuntimeError) do
95140
@session.create_form_elicitation(
141+
related_request_id: "req-1",
96142
message: "Please provide your name",
97143
requested_schema: { type: "object", properties: { name: { type: "string" } } },
98144
)
@@ -108,6 +154,7 @@ def handle_request(request); end
108154
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } })
109155

110156
result = @session.create_url_elicitation(
157+
related_request_id: "req-1",
111158
message: "Please authorize access",
112159
url: "https://example.com/oauth",
113160
elicitation_id: "abc-123",
@@ -128,6 +175,7 @@ def handle_request(request); end
128175
test "#create_url_elicitation raises error when client does not support url mode" do
129176
error = assert_raises(RuntimeError) do
130177
@session.create_url_elicitation(
178+
related_request_id: "req-1",
131179
message: "Please authorize access",
132180
url: "https://example.com/oauth",
133181
elicitation_id: "abc-123",
@@ -146,6 +194,7 @@ def handle_request(request); end
146194

147195
error = assert_raises(RuntimeError) do
148196
@session.create_url_elicitation(
197+
related_request_id: "req-1",
149198
message: "Please authorize access",
150199
url: "https://example.com/oauth",
151200
elicitation_id: "abc-123",

0 commit comments

Comments
 (0)