Skip to content

Commit a793dde

Browse files
authored
Merge pull request #440 from koic/mcp_2026_07_28_spec_associate_server_requests
Associate server-to-client requests with the originating client request per SEP-2260
2 parents e1fabe3 + 6ddcc49 commit a793dde

8 files changed

Lines changed: 323 additions & 16 deletions

File tree

‎README.md‎

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

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

11771183
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,17 @@ 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)
111+
106112
unless client_capabilities&.dig(:roots)
107113
raise "Client does not support roots."
108114
end
@@ -119,16 +125,28 @@ def ping(related_request_id: nil)
119125
end
120126

121127
# Sends a `sampling/createMessage` request scoped to this session.
128+
#
129+
# Per SEP-2260, the request must be associated with an originating client
130+
# request; prefer `server_context.create_sampling_message` inside a handler,
131+
# which stamps the association automatically.
122132
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
123133
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
124134
# APIs instead.
125135
def create_sampling_message(related_request_id: nil, **kwargs)
136+
warn_unassociated_request(__method__, related_request_id)
137+
126138
params = @server.build_sampling_params(client_capabilities, **kwargs)
127139
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
128140
end
129141

130142
# Sends an `elicitation/create` request (form mode) scoped to this session.
143+
#
144+
# Per SEP-2260, the request must be associated with an originating client
145+
# request; prefer `server_context.create_form_elicitation` inside a handler,
146+
# which stamps the association automatically.
131147
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
148+
warn_unassociated_request(__method__, related_request_id)
149+
132150
unless client_capabilities&.dig(:elicitation)
133151
raise "Client does not support elicitation. " \
134152
"The client must declare the `elicitation` capability during initialization."
@@ -139,7 +157,13 @@ def create_form_elicitation(message:, requested_schema:, related_request_id: nil
139157
end
140158

141159
# Sends an `elicitation/create` request (URL mode) scoped to this session.
160+
#
161+
# Per SEP-2260, the request must be associated with an originating client
162+
# request; prefer `server_context.create_url_elicitation` inside a handler,
163+
# which stamps the association automatically.
142164
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
165+
warn_unassociated_request(__method__, related_request_id)
166+
143167
unless client_capabilities&.dig(:elicitation, :url)
144168
raise "Client does not support URL mode elicitation. " \
145169
"The client must declare the `elicitation.url` capability during initialization."
@@ -264,5 +288,21 @@ def forward_to_transport(transport_method, method, params, kwargs)
264288
# The explicit splat suppresses that conversion and is a no-op when `supported` is empty.
265289
transport_method.call(method, params, **supported)
266290
end
291+
292+
# Per SEP-2260, servers MUST send `roots/list`, `sampling/createMessage`, and `elicitation/create` only
293+
# in association with an originating client request (`ping` is exempt). A request without `related_request_id:` is
294+
# delivered on the standalone GET stream by the Streamable HTTP transport, which the 2026-07-28 spec forbids;
295+
# warn so callers migrate to the `server_context` helpers before this becomes an error.
296+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260
297+
def warn_unassociated_request(method_name, related_request_id)
298+
return if related_request_id
299+
300+
warn(
301+
"Calling `ServerSession##{method_name}` without `related_request_id:` is deprecated (SEP-2260): " \
302+
"server-to-client #{method_name} requests must be associated with an originating client request. " \
303+
"Use `server_context.#{method_name}` inside a handler, or pass `related_request_id:` explicitly.",
304+
uplevel: 2,
305+
)
306+
end
267307
end
268308
end

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2891,6 +2891,67 @@ def string
28912891
assert_equal "Hi there", result[:content][:text]
28922892
end
28932893

2894+
test "send_request with related_request_id rides the originating POST stream, not the GET stream" do
2895+
# Per SEP-2260, server-to-client requests associated with a client request are delivered on
2896+
# that request's POST SSE response stream rather than the standalone GET stream.
2897+
init_request = create_rack_request(
2898+
"POST",
2899+
"/",
2900+
{ "CONTENT_TYPE" => "application/json" },
2901+
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
2902+
)
2903+
init_response = @transport.handle_request(init_request)
2904+
session_id = init_response[1]["Mcp-Session-Id"]
2905+
2906+
# Connect GET SSE.
2907+
get_io = StringIO.new
2908+
get_request = create_rack_request(
2909+
"GET",
2910+
"/",
2911+
{ "HTTP_MCP_SESSION_ID" => session_id },
2912+
)
2913+
response = @transport.handle_request(get_request)
2914+
response[2].call(get_io) if response[2].is_a?(Proc)
2915+
sleep(0.1)
2916+
2917+
# Register an in-flight POST request stream for "req-1".
2918+
post_io = StringIO.new
2919+
@transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { "req-1" => post_io }
2920+
2921+
result_queue = Queue.new
2922+
Thread.new do
2923+
result = @transport.send_request(
2924+
"roots/list",
2925+
nil,
2926+
session_id: session_id,
2927+
related_request_id: "req-1",
2928+
)
2929+
result_queue.push(result)
2930+
end
2931+
2932+
sleep(0.1)
2933+
2934+
post_io.rewind
2935+
post_output = post_io.read
2936+
assert_includes post_output, "roots/list"
2937+
2938+
get_io.rewind
2939+
refute_includes get_io.read, "roots/list"
2940+
2941+
# Respond so the background send_request completes.
2942+
data_lines = post_output.lines.select { |line| line.start_with?("data: ") }
2943+
request_id = JSON.parse(data_lines.first.sub("data: ", ""))["id"]
2944+
client_response = create_rack_request(
2945+
"POST",
2946+
"/",
2947+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
2948+
{ jsonrpc: "2.0", id: request_id, result: { roots: [] } }.to_json,
2949+
)
2950+
@transport.handle_request(client_response)
2951+
2952+
assert_equal({ roots: [] }, result_queue.pop)
2953+
end
2954+
28942955
test "send_request ignores response from wrong session" do
28952956
# Create two sessions.
28962957
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)