Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,12 @@ Roots define the boundaries of where a server can operate, providing a list of d
- **Client Capability**: Clients must declare `roots` capability during initialization
- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change

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

**Using Roots in Tools:**

Tools that accept a `server_context:` parameter can call `list_roots` on it.
Expand Down
12 changes: 12 additions & 0 deletions lib/mcp/server_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def notify_resources_updated(uri:)
end

# Delegates to the session so the request is scoped to the originating client.
# The originating request id is stamped as `related_request_id`, satisfying
# SEP-2260's requirement that server-to-client requests be associated with
# the client request being processed.
# @deprecated MCP Roots (`roots/list` and
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
Expand Down Expand Up @@ -91,6 +94,11 @@ def ping
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support sampling.
#
# The originating request id is stamped as `related_request_id` and cannot be
# overridden by callers (the literal keyword after `**kwargs` wins),
# satisfying SEP-2260's requirement that server-to-client requests be
# associated with the client request being processed.
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
# APIs instead.
Expand All @@ -107,6 +115,8 @@ def create_sampling_message(**kwargs)
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support elicitation.
# The originating request id is stamped as a non-overridable
# `related_request_id`, as with `create_sampling_message` (SEP-2260).
def create_form_elicitation(**kwargs)
if @notification_target.respond_to?(:create_form_elicitation)
@notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
Expand All @@ -119,6 +129,8 @@ def create_form_elicitation(**kwargs)

# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
# The originating request id is stamped as a non-overridable `related_request_id`,
# as with `create_sampling_message` (SEP-2260).
def create_url_elicitation(**kwargs)
if @notification_target.respond_to?(:create_url_elicitation)
@notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
Expand Down
40 changes: 40 additions & 0 deletions lib/mcp/server_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,17 @@ def client_capabilities
end

# Sends a `roots/list` request scoped to this session.
#
# Per SEP-2260, the request must be associated with an originating client
# request; prefer `server_context.list_roots` inside a handler, which stamps
# the association automatically.
# @deprecated MCP Roots (`roots/list` and
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
# server configuration, or environment variables instead.
def list_roots(related_request_id: nil)
warn_unassociated_request(__method__, related_request_id)

unless client_capabilities&.dig(:roots)
raise "Client does not support roots."
end
Expand All @@ -119,16 +125,28 @@ def ping(related_request_id: nil)
end

# Sends a `sampling/createMessage` request scoped to this session.
#
# Per SEP-2260, the request must be associated with an originating client
# request; prefer `server_context.create_sampling_message` inside a handler,
# which stamps the association automatically.
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
# APIs instead.
def create_sampling_message(related_request_id: nil, **kwargs)
warn_unassociated_request(__method__, related_request_id)

params = @server.build_sampling_params(client_capabilities, **kwargs)
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
end

# Sends an `elicitation/create` request (form mode) scoped to this session.
#
# Per SEP-2260, the request must be associated with an originating client
# request; prefer `server_context.create_form_elicitation` inside a handler,
# which stamps the association automatically.
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
warn_unassociated_request(__method__, related_request_id)

unless client_capabilities&.dig(:elicitation)
raise "Client does not support elicitation. " \
"The client must declare the `elicitation` capability during initialization."
Expand All @@ -139,7 +157,13 @@ def create_form_elicitation(message:, requested_schema:, related_request_id: nil
end

# Sends an `elicitation/create` request (URL mode) scoped to this session.
#
# Per SEP-2260, the request must be associated with an originating client
# request; prefer `server_context.create_url_elicitation` inside a handler,
# which stamps the association automatically.
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
warn_unassociated_request(__method__, related_request_id)

unless client_capabilities&.dig(:elicitation, :url)
raise "Client does not support URL mode elicitation. " \
"The client must declare the `elicitation.url` capability during initialization."
Expand Down Expand Up @@ -264,5 +288,21 @@ def forward_to_transport(transport_method, method, params, kwargs)
# The explicit splat suppresses that conversion and is a no-op when `supported` is empty.
transport_method.call(method, params, **supported)
end

# Per SEP-2260, servers MUST send `roots/list`, `sampling/createMessage`, and `elicitation/create` only
# in association with an originating client request (`ping` is exempt). A request without `related_request_id:` is
# delivered on the standalone GET stream by the Streamable HTTP transport, which the 2026-07-28 spec forbids;
# warn so callers migrate to the `server_context` helpers before this becomes an error.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260
def warn_unassociated_request(method_name, related_request_id)
return if related_request_id

warn(
"Calling `ServerSession##{method_name}` without `related_request_id:` is deprecated (SEP-2260): " \
"server-to-client #{method_name} requests must be associated with an originating client request. " \
"Use `server_context.#{method_name}` inside a handler, or pass `related_request_id:` explicitly.",
uplevel: 2,
)
end
end
end
61 changes: 61 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2801,6 +2801,67 @@ def string
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" }.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(
Expand Down
81 changes: 81 additions & 0 deletions test/mcp/server_context_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,87 @@ class ServerContextTest < ActiveSupport::TestCase
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
Expand Down
49 changes: 49 additions & 0 deletions test/mcp/server_elicitation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,52 @@ def handle_request(request); end
@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",
Expand All @@ -76,6 +120,7 @@ def handle_request(request); end

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" } } },
)
Expand All @@ -93,6 +138,7 @@ def handle_request(request); end

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" } } },
)
Expand All @@ -108,6 +154,7 @@ def handle_request(request); end
@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",
Expand All @@ -128,6 +175,7 @@ def handle_request(request); end
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",
Expand All @@ -146,6 +194,7 @@ def handle_request(request); end

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",
Expand Down
Loading