diff --git a/README.md b/README.md index b986c262..91b46736 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 92b1abd6..dc5b20a5 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -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, @@ -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. @@ -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) @@ -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) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 502c6163..754c1d00 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -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 @@ -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." @@ -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." @@ -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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9b26949a..6b0789fa 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -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( diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 44fa12e4..91258052 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -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 diff --git a/test/mcp/server_elicitation_test.rb b/test/mcp/server_elicitation_test.rb index 66a70f42..03f36090 100644 --- a/test/mcp/server_elicitation_test.rb +++ b/test/mcp/server_elicitation_test.rb @@ -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", @@ -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" } } }, ) @@ -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" } } }, ) @@ -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", @@ -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", @@ -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", diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index c887398f..da448165 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -110,7 +110,7 @@ def close; end 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) @@ -118,11 +118,31 @@ def close; end 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) @@ -130,7 +150,7 @@ def close; end 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 @@ -194,7 +214,7 @@ def close; end 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 diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index dd123564..9c6985b3 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -48,6 +48,7 @@ def close; end 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 @@ def close; end 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 @@ def close; end 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 @@ def close; end 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 @@ def close; end 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 @@ def close; end @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 @@ def close; end 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 @@ def close; end 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 @@ def close; end 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 @@ def close; end # 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 @@ def close; end 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,