From db40143402d65b4fb6923cec42d2d72cb89b3874 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 3 Mar 2026 17:23:29 +0900 Subject: [PATCH 001/247] Reject duplicate SSE connections with 409 to prevent stream hijacking An attacker who obtains a valid session ID could issue a GET request to establish a new SSE stream, silently replacing the victim's stream. All subsequent server-to-client messages (tool responses, notifications) would then be delivered to the attacker instead of the legitimate client, with no indication to the victim. The fix mirrors the Python SDK's approach: when a GET request arrives for a session that already has an active SSE stream, respond with HTTP 409 Conflict ("Only one SSE stream is allowed per session") and leave the existing connection intact. Additionally, `store_stream_for_session` checks for an existing stream under the mutex before storing a new one, closing the TOCTOU window between the HTTP-level check and the deferred stream assignment. Note: exploiting this requires prior knowledge of the session ID (a `SecureRandom.uuid`), so the practical severity is low in typical local MCP deployments. The change is still worthwhile as defense-in-depth, especially for multi-user or network-exposed server deployments. --- .../transports/streamable_http_transport.rb | 22 +++++-- .../streamable_http_transport_test.rb | 57 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index ff9977d..3df47b7 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -142,6 +142,7 @@ module MCP return missing_session_id_response unless session_id return session_not_found_response unless session_exists?(session_id) + return session_already_connected_response if get_session_stream(session_id) setup_sse_stream(session_id) end @@ -315,6 +316,14 @@ module MCP [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] end + def session_already_connected_response + [ + 409, + { "Content-Type" => "application/json" }, + [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json], + ] + end + def setup_sse_stream(session_id) body = create_sse_body(session_id) @@ -329,17 +338,22 @@ module MCP def create_sse_body(session_id) proc do |stream| - store_stream_for_session(session_id, stream) - start_keepalive_thread(session_id) + stored = store_stream_for_session(session_id, stream) + start_keepalive_thread(session_id) if stored end end def store_stream_for_session(session_id, stream) @mutex.synchronize do - if @sessions[session_id] - @sessions[session_id][:stream] = stream + session = @sessions[session_id] + if session && !session[:stream] + session[:stream] = stream else + # Either session was removed, or another request already established a stream. stream.close + # `stream.close` may return a truthy value depending on the stream class. + # Explicitly return nil to guarantee a falsy return for callers. + nil 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 56f0893..8d61dfc 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -272,6 +272,63 @@ module MCP assert_equal "Missing session ID", body["error"] end + test "rejects duplicate SSE connection with 409" do + # Create a session + 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"] + + # Simulate an active SSE stream by storing a stream object in the session + mock_stream = StringIO.new + @transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + + # Attempt a second GET request for the same session + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(get_request) + assert_equal 409, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"] + end + + test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do + # Create a session + 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"] + + # Establish stream A + stream_a = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_a) + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + + # Attempt to store stream B (simulating a racing request) + stream_b = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_b) + + # Stream A should still be the active stream + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + + # Stream B should have been closed + assert stream_b.closed? + end + test "handles GET request with invalid session ID" do request = create_rack_request( "GET", From b3125d46054beb04128a5958a21ee0d706af0233 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 26 Mar 2026 04:03:59 +0900 Subject: [PATCH 002/247] [Doc] Align stdio with specification Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/pull/262#discussion_r2957734483. --- examples/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/README.md b/examples/README.md index 2bebdcd..71700e1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ This directory contains examples of how to use the Model Context Protocol (MCP) ## Available Examples -### 1. STDIO Server (`stdio_server.rb`) +### 1. stdio Server (`stdio_server.rb`) A simple server that communicates over standard input/output. This is useful for desktop applications and command-line tools. @@ -15,9 +15,9 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":0,"method":"tools/list"} ``` -### 2. STDIO Client (`stdio_client.rb`) +### 2. stdio Client (`stdio_client.rb`) -A client that connects to the STDIO server using the `MCP::Client::Stdio` transport. +A client that connects to the stdio server using the `MCP::Client::Stdio` transport. This demonstrates how to use the SDK's built-in client classes to interact with a server subprocess. **Usage:** From 805e020d4021f0acb65b601bf215a96619e206c3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 26 Mar 2026 04:34:15 +0900 Subject: [PATCH 003/247] Remove undocumented handler override methods ## Motivation and Context The following public methods allow replacing the SDK's built-in request handlers: - `tools_call_handler` - `tools_list_handler` - `resources_list_handler` - `resources_templates_list_handler` - `prompts_list_handler` - `prompts_get_handler` These were introduced in Internal Release 0.2.0 (https://github.com/modelcontextprotocol/ruby-sdk/commit/6004f42) as part of "allow setting handlers on Server", before declarative APIs (`define_tool`, `Prompt` class, `Resource` class) were fully established. Now that those declarative APIs exist, these handler overrides are redundant: none are documented in the README.md, and no usage exists outside of the SDK's own tests. These handler overrides also create architectural problems. Since custom handlers cannot receive session context, they would not work correctly with a per-session architecture, such as session-scoped notifications. ### Changes - Removed `tools_call_handler`, `tools_list_handler`, `resources_list_handler`, `resources_templates_list_handler`, `prompts_list_handler`, and `prompts_get_handler` from `Server`. - Removed the associated tests. - Retained `resources_read_handler` (documented in the README.md and actively used in examples and conformance server, because resource reading is application-specific). ## Breaking Change The following methods are removed without a deprecation period: `tools_call_handler`, `tools_list_handler`, `resources_list_handler`, `resources_templates_list_handler`, `prompts_list_handler`, `prompts_get_handler`. These were never documented in the README.md and have no known usage outside of the SDK's own tests. As a result, little to no impact on users is expected, and a deprecation warning would have no practical audience. Users who relied on these should use `define_tool`, prompt/resource registration, or `resources_read_handler` instead. --- lib/mcp/server.rb | 30 ++-------- test/mcp/server_test.rb | 123 ---------------------------------------- 2 files changed, 6 insertions(+), 147 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5255207..9badde5 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -199,34 +199,16 @@ module MCP report_exception(e, { notification: "log_message" }) end - def resources_list_handler(&block) - @handlers[Methods::RESOURCES_LIST] = block - end - + # Sets a custom handler for `resources/read` requests. + # The block receives the parsed request params and should return resource + # contents. The return value is set as the `contents` field of the response. + # + # @yield [params] The request params containing `:uri`. + # @yieldreturn [Array, Hash] Resource contents. def resources_read_handler(&block) @handlers[Methods::RESOURCES_READ] = block end - def resources_templates_list_handler(&block) - @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block - end - - def tools_list_handler(&block) - @handlers[Methods::TOOLS_LIST] = block - end - - def tools_call_handler(&block) - @handlers[Methods::TOOLS_CALL] = block - end - - def prompts_list_handler(&block) - @handlers[Methods::PROMPTS_LIST] = block - end - - def prompts_get_handler(&block) - @handlers[Methods::PROMPTS_GET] = block - end - private def validate! diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index fac919b..1b12412 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -270,23 +270,6 @@ module MCP assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) end - test "#tools_list_handler sets the tools/list handler" do - @server.tools_list_handler do - [{ name: "hammer", description: "Hammer time!" }] - end - - request = { - jsonrpc: "2.0", - method: "tools/list", - id: 1, - } - - response = @server.handle(request) - result = response[:result] - assert_equal({ tools: [{ name: "hammer", description: "Hammer time!" }] }, result) - assert_instrumentation_data({ method: "tools/list" }) - end - test "#handle tools/call executes tool and returns result" do tool_name = "test_tool" tool_args = { arg: "value" } @@ -564,24 +547,6 @@ module MCP assert_includes response[:error][:data], "Tool not found: unknown_tool" end - test "#tools_call_handler sets the tools/call handler" do - @server.tools_call_handler do |request| - tool_name = request[:name] - Tool::Response.new("#{tool_name} called successfully").to_h - end - - request = { - jsonrpc: "2.0", - method: "tools/call", - params: { name: "my_tool", arguments: {} }, - id: 1, - } - - response = @server.handle(request) - assert_equal({ content: "my_tool called successfully", isError: false }, response[:result]) - assert_instrumentation_data({ method: "tools/call" }) - end - test "#handle prompts/list returns list of prompts" do request = { jsonrpc: "2.0", @@ -594,22 +559,6 @@ module MCP assert_instrumentation_data({ method: "prompts/list" }) end - test "#prompts_list_handler sets the prompts/list handler" do - @server.prompts_list_handler do - [{ name: "foo_prompt", description: "Foo prompt" }] - end - - request = { - jsonrpc: "2.0", - method: "prompts/list", - id: 1, - } - - response = @server.handle(request) - assert_equal({ prompts: [{ name: "foo_prompt", description: "Foo prompt" }] }, response[:result]) - assert_instrumentation_data({ method: "prompts/list" }) - end - test "#handle prompts/get returns templated prompt" do request = { jsonrpc: "2.0", @@ -669,32 +618,6 @@ module MCP }) end - test "#prompts_get_handler sets the prompts/get handler" do - @server.prompts_get_handler do |request| - prompt_name = request[:name] - Prompt::Result.new( - description: prompt_name, - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new(request[:arguments]["foo"])), - ], - ).to_h - end - - request = { - jsonrpc: "2.0", - method: "prompts/get", - id: 1, - params: { name: "foo_bar_prompt", arguments: { "foo" => "bar" } }, - } - - response = @server.handle(request) - assert_equal( - { description: "foo_bar_prompt", messages: [{ role: "user", content: { type: "text", text: "bar" } }] }, - response[:result], - ) - assert_instrumentation_data({ method: "prompts/get" }) - end - test "#handle resources/list returns a list of resources" do request = { jsonrpc: "2.0", @@ -707,25 +630,6 @@ module MCP assert_instrumentation_data({ method: "resources/list" }) end - test "#resources_list_handler sets the resources/list handler" do - @server.resources_list_handler do - [{ uri: "https://test_resource.invalid", name: "test-resource", title: "Test Resource", description: "Test resource" }] - end - - request = { - jsonrpc: "2.0", - method: "resources/list", - id: 1, - } - - response = @server.handle(request) - assert_equal( - { resources: [{ uri: "https://test_resource.invalid", name: "test-resource", title: "Test Resource", description: "Test resource" }] }, - response[:result], - ) - assert_instrumentation_data({ method: "resources/list" }) - end - test "#handle resources/read returns an empty array of contents by default" do request = { jsonrpc: "2.0", @@ -783,33 +687,6 @@ module MCP assert_instrumentation_data({ method: "resources/templates/list" }) end - test "#resources_templates_list_handler sets the resources/templates/list handler" do - @server.resources_templates_list_handler do - [{ uriTemplate: "test_resource_template/{id}", name: "Test resource template", description: "a template" }] - end - - request = { - jsonrpc: "2.0", - method: "resources/templates/list", - id: 1, - } - - response = @server.handle(request) - assert_equal( - { - resourceTemplates: [ - { - uriTemplate: "test_resource_template/{id}", - name: "Test resource template", - description: "a template", - }, - ], - }, - response[:result], - ) - assert_instrumentation_data({ method: "resources/templates/list" }) - end - test "#configure_logging_level returns empty hash on success" do response = @server.handle( { From a2575b24e5f48943f4f0b66b6632b917fe803116 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 27 Mar 2026 11:47:34 +0900 Subject: [PATCH 004/247] Use accessor method in `server_context_with_meta` instead of ivar ## Motivation and Context `server_context_with_meta` reads `@server_context` (the instance variable) directly instead of calling the `server_context` accessor method. This prevents subclasses from overriding `server_context`. Ffor example, to provide thread-local context in multi-threaded servers like Puma. Fixes #271. ## How Has This Been Tested? Added a regression test and passed. ## Breaking Changes None. This restores the expected behavior of `attr_accessor :server_context`. --- lib/mcp/server.rb | 8 ++++---- test/mcp/server_test.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5255207..7646b29 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -531,14 +531,14 @@ module MCP def server_context_with_meta(request) meta = request[:_meta] - if meta && @server_context.is_a?(Hash) - context = @server_context.dup + if meta && server_context.is_a?(Hash) + context = server_context.dup context[:_meta] = meta context - elsif meta && @server_context.nil? + elsif meta && server_context.nil? { _meta: meta } else - @server_context + server_context end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index fac919b..c7d7ec7 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1731,5 +1731,31 @@ module MCP end end end + + test "server_context_with_meta uses accessor method, not ivar directly" do + subclass = Class.new(Server) do + def server_context + { custom: "from_accessor" } + end + end + + server = subclass.new(name: "test", tools: []) + + received_context = nil + server.define_tool(name: "ctx_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "ctx_tool", arguments: {} }, + } + + server.handle(request) + assert_equal "from_accessor", received_context[:custom] + end end end From 8fbc2b46313479cdbf5afbb44045583f94e813e3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 26 Mar 2026 23:09:35 +0900 Subject: [PATCH 005/247] Release 0.9.2 --- CHANGELOG.md | 7 +++++++ lib/mcp/version.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3620a3f..f7fa070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.2] - 2026-03-27 + +### Fixed + +- Use accessor method in `server_context_with_meta` instead of ivar (#273) +- Reject duplicate SSE connections with 409 to prevent stream hijacking + ## [0.9.1] - 2026-03-23 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index da708db..3a5b450 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.9.1" + VERSION = "0.9.2" end From 88d9bed69d3b6110e6a340388f30569abd192d5c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 28 Mar 2026 02:45:58 +0900 Subject: [PATCH 006/247] Reject POST requests without session ID in stateful mode Per the MCP specification (Streamable HTTP > Session Management): > Servers that require a session ID SHOULD respond to requests without an `Mcp-Session-Id` header > (other than initialization) with HTTP 400 Bad Request. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management Previously, non-initialize POST requests without `Mcp-Session-Id` in stateful mode were processed with HTTP 200 (for regular requests) or HTTP 202 (for notifications/responses). This change adds an explicit check in `handle_post` to return HTTP 400 Bad Request when the session ID is missing in stateful mode for all non-initialize requests, aligning with the specification. --- .../transports/streamable_http_transport.rb | 10 +++- .../streamable_http_transport_test.rb | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index f1a5d9d..2b658ec 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -120,10 +120,14 @@ module MCP if body["method"] == "initialize" handle_initialization(body_string, body) - elsif notification?(body) || response?(body) - handle_accepted else - handle_regular_request(body_string, session_id) + return missing_session_id_response if !@stateless && !session_id + + if notification?(body) || response?(body) + handle_accepted + else + handle_regular_request(body_string, session_id) + end end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 4b346c4..94b6226 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -272,6 +272,62 @@ module MCP assert_equal "Missing session ID", body["error"] end + test "rejects POST request without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "rejects notification without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "rejects response without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", id: "1", result: {} }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "allows POST request without session ID in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = stateless_transport.handle_request(request) + assert_equal 200, response[0] + end + test "rejects duplicate SSE connection with 409" do # Create a session init_request = create_rack_request( From 719e5e7fee4ae2fffb398daf71daa54734863b50 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 26 Mar 2026 00:15:30 +0900 Subject: [PATCH 007/247] Introduce `ServerSession` for per-connection state ## Motivation and Context The Ruby SDK uses a 1-Server-to-many-sessions model where session-specific state (`@client`, `@logging_message_notification`) is stored on the shared `Server` instance. This causes the last client to connect to overwrite state for all sessions, and requires `session_id` to be threaded through method signatures for session-scoped notifications. The Python SDK avoids this by creating a `ServerSession` per connection that wraps a shared `Server`. Each session holds its own state and naturally scopes notifications through its own stream. ### Changes - Added `MCP::ServerSession` class holding per-session state: client info, logging configuration, and a `transport` reference for session-scoped notification delivery. - `StreamableHTTPTransport#handle_initialization` creates a `ServerSession` per `initialize` request and stores it in the session hash. - `handle_regular_request` routes requests through the session's `ServerSession` instead of the shared `Server` directly. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword (`ServerSession` instance). - `Server#init` stores client info on the session when available. - `Server#configure_logging_level` stores logging config on the session. - `ServerContext` and `Progress` accept a `notification_target:` which can be either a `ServerSession` (session-scoped) or a `Server` (broadcast). No `session_id:` parameter needed. - `ServerContext#notify_progress` and `#notify_log_message` delegate to the `notification_target` without `session_id` threading. - `StdioTransport` creates a single `ServerSession` on `open`, making the session model transparent across both transports. ### Design Decision This follows the Python SDK's "shared `Server` + per-connection `Session`" pattern. The `Server` holds configuration (tools, prompts, resources, handlers). Each `ServerSession` holds per-connection state (client info, logging level, stream writer). Notifications from `ServerSession` go only to that session's stream. ## How Has This Been Tested? All existing tests pass. All conformance tests pass. Added tests verifying: - Session-scoped log notification is sent only to the originating session. - Session-scoped progress notification is sent only to the originating session. - Each session stores its own client info independently. - Each session stores its own logging level independently. - `StdioTransport#open` creates a `ServerSession` and stores client info on it. ## Breaking Change None for end users. The public API (`Server.new`, `define_tool`,`server_context.report_progress`, `server_context.notify_log_message`, etc.) is unchanged. The following internal API changes affect only SDK internals: - `ServerContext.new` now requires `notification_target:` instead of just`progress:`. - `Progress.new` now takes `notification_target:` instead of `server:`. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword. --- AGENTS.md | 16 +- README.md | 9 + lib/mcp.rb | 1 + lib/mcp/progress.rb | 6 +- lib/mcp/server.rb | 60 +++- lib/mcp/server/transports/stdio_transport.rb | 8 +- .../transports/streamable_http_transport.rb | 33 +- lib/mcp/server_context.rb | 28 +- lib/mcp/server_session.rb | 79 +++++ lib/mcp/transport.rb | 4 +- test/mcp/progress_test.rb | 6 +- .../server/transports/stdio_transport_test.rb | 32 ++ .../streamable_http_transport_test.rb | 292 +++++++++++++++++- test/mcp/server_context_test.rb | 22 +- test/mcp/server_notification_test.rb | 18 ++ test/mcp/server_test.rb | 14 + 16 files changed, 564 insertions(+), 64 deletions(-) create mode 100644 lib/mcp/server_session.rb diff --git a/AGENTS.md b/AGENTS.md index 6b269d7..cf15786 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,18 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing **MCP::Server** (`lib/mcp/server.rb`): -- Main server class handling JSON-RPC requests +- Main server class handling JSON-RPC requests and holding shared configuration (tools, prompts, resources, handlers, capabilities) - Implements MCP protocol methods: initialize, ping, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read - Supports custom method registration via `define_custom_method` - Handles instrumentation, exception reporting, and notifications - Uses JsonRpcHandler for request processing +**MCP::ServerSession** (`lib/mcp/server_session.rb`): + +- Per-connection state: client info, logging level +- Created by the transport layer for each client connection +- Delegates request handling to the shared `Server` + **MCP::Client** (`lib/mcp/client.rb`): - Client interface for communicating with MCP servers @@ -95,6 +101,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Protocol version 2025-03-26+ supports tool annotations (destructive_hint, idempotent_hint, etc.) - Validation is configurable via `configuration.validate_tool_call_arguments` +**Session Architecture**: + +- `Server` holds shared configuration (tools, prompts, resources, handlers) +- `ServerSession` holds per-connection state (client info, logging level) +- Both `StdioTransport` and `StreamableHTTPTransport` create a `ServerSession` per connection, making the session model transparent across transports +- Session-scoped notifications (`notify_progress`, `notify_log_message`) are sent only to the originating client via `ServerSession` +- Server-wide notifications (`notify_tools_list_changed`, etc.) broadcast to all sessions via `Server` + **Context Passing**: - `server_context` hash passed through tool/prompt calls for request-specific data diff --git a/README.md b/README.md index 6ac29be..9e8a096 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,15 @@ The server provides the following notification methods: - `notify_progress` - Send a progress notification for long-running operations - `notify_log_message` - Send a structured logging notification message +#### Session Scoping + +When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: + +- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client. +No extra configuration is needed. +- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, +as they represent server-wide state changes. These should be called on the `server` instance directly. + #### Notification Format Notifications follow the JSON-RPC 2.0 specification and use these method names: diff --git a/lib/mcp.rb b/lib/mcp.rb index 98551e7..79082c4 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -15,6 +15,7 @@ module MCP autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" autoload :Server, "mcp/server" + autoload :ServerSession, "mcp/server_session" autoload :Tool, "mcp/tool" class << self diff --git a/lib/mcp/progress.rb b/lib/mcp/progress.rb index 7d27a51..7c36a94 100644 --- a/lib/mcp/progress.rb +++ b/lib/mcp/progress.rb @@ -2,15 +2,15 @@ module MCP class Progress - def initialize(server:, progress_token:) - @server = server + def initialize(notification_target:, progress_token:) + @notification_target = notification_target @progress_token = progress_token end def report(progress, total: nil, message: nil) return unless @progress_token - @server.notify_progress( + @notification_target.notify_progress( progress_token: @progress_token, progress: progress, total: total, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 4ef4b51..ffcce58 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -111,15 +111,29 @@ module MCP @transport = transport end - def handle(request) + # Processes a parsed JSON-RPC request and returns the response as a Hash. + # + # @param request [Hash] A parsed JSON-RPC request. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle` for session-scoped notification delivery. + # When `nil`, notifications broadcast to all sessions. + # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. + def handle(request, session: nil) JsonRpcHandler.handle(request) do |method| - handle_request(request, method) + handle_request(request, method, session: session) end end - def handle_json(request) + # Processes a JSON-RPC request string and returns the response as a JSON string. + # + # @param request [String] A JSON-RPC request as a JSON string. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle_json` for session-scoped notification delivery. + # When `nil`, notifications broadcast to all sessions. + # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. + def handle_json(request, session: nil) JsonRpcHandler.handle_json(request) do |method| - handle_request(request, method) + handle_request(request, method, session: session) end end @@ -279,11 +293,12 @@ module MCP end end - def handle_request(request, method) + def handle_request(request, method, session: nil) handler = @handlers[method] unless handler instrument_call("unsupported_method") do - add_instrumentation_data(client: @client) if @client + client = session&.client || @client + add_instrumentation_data(client: client) if client end return end @@ -293,6 +308,8 @@ module MCP ->(params) { instrument_call(method) do result = case method + when Methods::INITIALIZE + init(params, session: session) when Methods::TOOLS_LIST { tools: @handlers[Methods::TOOLS_LIST].call(params) } when Methods::PROMPTS_LIST @@ -303,10 +320,15 @@ module MCP { contents: @handlers[Methods::RESOURCES_READ].call(params) } when Methods::RESOURCES_TEMPLATES_LIST { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } + when Methods::TOOLS_CALL + call_tool(params, session: session) + when Methods::LOGGING_SET_LEVEL + configure_logging_level(params, session: session) else @handlers[method].call(params) end - add_instrumentation_data(client: @client) if @client + client = session&.client || @client + add_instrumentation_data(client: client) if client result rescue => e @@ -342,8 +364,14 @@ module MCP }.compact end - def init(params) - @client = params[:clientInfo] if params + def init(params, session: nil) + if params + if session + session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) + else + @client = params[:clientInfo] + end + end protocol_version = params[:protocolVersion] if params negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) @@ -371,7 +399,7 @@ module MCP }.compact end - def configure_logging_level(request) + def configure_logging_level(request, session: nil) if capabilities[:logging].nil? raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) end @@ -381,6 +409,7 @@ module MCP raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params) end + session&.configure_logging(logging_message_notification) @logging_message_notification = logging_message_notification {} @@ -390,7 +419,7 @@ module MCP @tools.values.map(&:to_h) end - def call_tool(request) + def call_tool(request, session: nil) tool_name = request[:name] tool = tools[tool_name] @@ -422,7 +451,7 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token) + call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session) rescue RequestHandlerError raise rescue => e @@ -491,12 +520,13 @@ module MCP parameters.any? { |type, name| type == :keyrest || name == :server_context } end - def call_tool_with_args(tool, arguments, context, progress_token: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil) args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) - progress = Progress.new(server: self, progress_token: progress_token) - server_context = ServerContext.new(context, progress: progress) + notification_target = session || self + progress = Progress.new(notification_target: notification_target, progress_token: progress_token) + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) tool.call(**args, server_context: server_context).to_h else tool.call(**args).to_h diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index d6d52dd..9d54cec 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -10,17 +10,19 @@ module MCP STATUS_INTERRUPTED = Signal.list["INT"] + 128 def initialize(server) - @server = server + super(server) @open = false + @session = nil $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") - super end def open @open = true + @session = ServerSession.new(server: @server, transport: self) while @open && (line = $stdin.gets) - handle_json_request(line.strip) + response = @session.handle_json(line.strip) + send_response(response) if response end rescue Interrupt warn("\nExiting...") diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index f1a5d9d..e74d23e 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -10,7 +10,7 @@ module MCP class StreamableHTTPTransport < Transport def initialize(server, stateless: false) super(server) - # { session_id => { stream: stream_object } + # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`. @sessions = {} @mutex = Mutex.new @@ -228,18 +228,25 @@ module MCP def handle_initialization(body_string, body) session_id = nil + server_session = nil unless @stateless session_id = SecureRandom.uuid + server_session = ServerSession.new(server: @server, transport: self, session_id: session_id) @mutex.synchronize do @sessions[session_id] = { stream: nil, + server_session: server_session, } end end - response = @server.handle_json(body_string) + response = if server_session + server_session.handle_json(body_string) + else + @server.handle_json(body_string) + end headers = { "Content-Type" => "application/json", @@ -255,16 +262,24 @@ module MCP end def handle_regular_request(body_string, session_id) - unless @stateless - if session_id && !session_exists?(session_id) - return session_not_found_response + server_session = nil + stream = nil + + if session_id && !@stateless + @mutex.synchronize do + session = @sessions[session_id] + return session_not_found_response unless session + + server_session = session[:server_session] + stream = session[:stream] end end - response = @server.handle_json(body_string) - - # Stream can be nil since stateless mode doesn't retain streams - stream = get_session_stream(session_id) if session_id + response = if server_session + server_session.handle_json(body_string) + else + @server.handle_json(body_string) + end if stream send_response_to_stream(stream, response, session_id) diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index b7241a6..458d2cd 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -2,15 +2,41 @@ module MCP class ServerContext - def initialize(context, progress:) + def initialize(context, progress:, notification_target:) @context = context @progress = progress + @notification_target = notification_target end + # Reports progress for the current tool operation. + # The notification is automatically scoped to the originating session. + # + # @param progress [Numeric] Current progress value. + # @param total [Numeric, nil] Total expected value. + # @param message [String, nil] Human-readable status message. def report_progress(progress, total: nil, message: nil) @progress.report(progress, total: total, message: message) end + # Sends a progress notification scoped to the originating session. + # + # @param progress_token [String, Integer] The token identifying the operation. + # @param progress [Numeric] Current progress value. + # @param total [Numeric, nil] Total expected value. + # @param message [String, nil] Human-readable status message. + def notify_progress(progress_token:, progress:, total: nil, message: nil) + @notification_target.notify_progress(progress_token: progress_token, progress: progress, total: total, message: message) + end + + # Sends a log message notification scoped to the originating session. + # + # @param data [Object] The log data to send. + # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`). + # @param logger [String, nil] Logger name. + def notify_log_message(data:, level:, logger: nil) + @notification_target.notify_log_message(data: data, level: level, logger: logger) + end + def method_missing(name, ...) if @context.respond_to?(name) @context.public_send(name, ...) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb new file mode 100644 index 0000000..a1cbfd0 --- /dev/null +++ b/lib/mcp/server_session.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "methods" + +module MCP + # Holds per-connection state for a single client session. + # Created by the transport layer; delegates request handling to the shared `Server`. + class ServerSession + attr_reader :session_id, :client, :logging_message_notification + + def initialize(server:, transport:, session_id: nil) + @server = server + @transport = transport + @session_id = session_id + @client = nil + @client_capabilities = nil # TODO: Use for per-session capability validation. + @logging_message_notification = nil + end + + def handle(request) + @server.handle(request, session: self) + end + + def handle_json(request_json) + @server.handle_json(request_json, session: self) + end + + # Called by `Server#init` during the initialization handshake. + def store_client_info(client:, capabilities: nil) + @client = client + @client_capabilities = capabilities + end + + # Called by `Server#configure_logging_level`. + def configure_logging(logging_message_notification) + @logging_message_notification = logging_message_notification + end + + # Sends a progress notification to this session only. + def notify_progress(progress_token:, progress:, total: nil, message: nil) + params = { + "progressToken" => progress_token, + "progress" => progress, + "total" => total, + "message" => message, + }.compact + + send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params) + rescue => e + @server.report_exception(e, notification: "progress") + end + + # Sends a log message notification to this session only. + def notify_log_message(data:, level:, logger: nil) + effective_logging = @logging_message_notification || @server.logging_message_notification + return unless effective_logging&.should_notify?(level) + + params = { "data" => data, "level" => level } + params["logger"] = logger if logger + + send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params) + rescue => e + @server.report_exception(e, { notification: "log_message" }) + end + + private + + # TODO: When Ruby 2.7 support is dropped, replace with a direct call: + # `@transport.send_notification(method, params, session_id: @session_id)` and + # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`. + def send_to_transport(method, params) + if @session_id + @transport.send_notification(method, params, session_id: @session_id) + else + @transport.send_notification(method, params) + end + end + end +end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index a995c3a..99b8892 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -36,8 +36,8 @@ module MCP send_response(response) if response end - # Send a notification to the client - # Returns true if the notification was sent successfully + # Send a notification to the client. + # Returns true if the notification was sent successfully. def send_notification(method, params = nil) raise NotImplementedError, "Subclasses must implement send_notification" end diff --git a/test/mcp/progress_test.rb b/test/mcp/progress_test.rb index 8477087..a0349b5 100644 --- a/test/mcp/progress_test.rb +++ b/test/mcp/progress_test.rb @@ -30,14 +30,14 @@ module MCP end test "#report is a no-op when progress_token is nil" do - progress = Progress.new(server: @server, progress_token: nil) + progress = Progress.new(notification_target: @server, progress_token: nil) progress.report(50, total: 100, message: "halfway") assert_equal 0, @transport.notifications.size end test "#report sends notification when progress_token is present" do - progress = Progress.new(server: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @server, progress_token: "token-1") progress.report(50, total: 100, message: "halfway") assert_equal 1, @transport.notifications.size @@ -50,7 +50,7 @@ module MCP end test "#report omits total and message when not provided" do - progress = Progress.new(server: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @server, progress_token: "token-1") progress.report(50) assert_equal 1, @transport.notifications.size diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index aa65ccb..59d3abe 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -101,6 +101,38 @@ module MCP end end + test "open creates a ServerSession and processes requests through it" do + request = { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "stdio-client", version: "1.0" }, + }, + } + input = StringIO.new(JSON.generate(request) + "\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + # Verify a session was created. + session = @transport.instance_variable_get(:@session) + assert_instance_of(ServerSession, session) + + # Verify client info was stored on the session, not on the server. + assert_equal({ name: "stdio-client", version: "1.0" }, session.client) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + test "handles invalid JSON requests" do invalid_json = "invalid json" output = StringIO.new diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 4b346c4..a2abab6 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -586,8 +586,8 @@ module MCP # Monkey-patch handle_json on the server to send a notification when called original_handle_json = @server.method(:handle_json) transport = @transport # Capture the transport in a local variable - @server.define_singleton_method(:handle_json) do |request| - result = original_handle_json.call(request) + @server.define_singleton_method(:handle_json) do |request, **kwargs| + result = original_handle_json.call(request, **kwargs) # Send notification while still in request context - broadcast to all sessions transport.send_notification("test_notification", { session: "current" }, **{}) result @@ -1357,29 +1357,289 @@ module MCP assert_equal([], response[2]) end - test "handle_regular_request checks session existence under mutex" do - 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"] - - @transport.expects(:session_exists?).with(session_id).returns(true) - + test "handle_regular_request returns 404 for unknown session_id" do request = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_SESSION_ID" => "nonexistent-session", }, { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, ) response = @transport.handle_request(request) - assert_equal(200, response[0]) + assert_equal(404, response[0]) + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]) + end + + test "session-scoped log notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "secret", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1. + tool_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + transport.handle_request(tool_request) + + # Session 1 should receive the log notification. + io1.rewind + assert_includes io1.read, "secret" + + # Session 2 should NOT receive the log notification. + io2.rewind + refute_includes io2.read, "secret" + end + + test "session-scoped progress notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100, message: "halfway") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1 with a progress token. + tool_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { + name: "progress_tool", + arguments: {}, + _meta: { progressToken: "token-1" }, + }, + }.to_json, + ) + transport.handle_request(tool_request) + + # Session 1 should receive the progress notification. + io1.rewind + assert_includes io1.read, "halfway" + + # Session 2 should NOT receive the progress notification. + io2.rewind + refute_includes io2.read, "halfway" + end + + test "each session stores its own client info independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + # Initialize session 1 with client "alpha". + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "alpha", version: "1.0" }, + }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + # Initialize session 2 with client "beta". + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "beta", version: "2.0" }, + }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Each session should have its own client info. + sessions = transport.instance_variable_get(:@sessions) + assert_equal({ name: "alpha", version: "1.0" }, sessions[session1][:server_session].client) + assert_equal({ name: "beta", version: "2.0" }, sessions[session2][:server_session].client) + end + + test "each session stores its own logging level independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + # Initialize two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Session 1 sets log level to "error". + set_level1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "3", + params: { level: "error" }, + }.to_json, + ) + transport.handle_request(set_level1) + + # Session 2 sets log level to "debug". + set_level2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session2 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "4", + params: { level: "debug" }, + }.to_json, + ) + transport.handle_request(set_level2) + + # Session 1 (error level) should not notify for "info", but should for "error". + session1_logging = transport.instance_variable_get(:@sessions)[session1][:server_session].logging_message_notification + refute session1_logging.should_notify?("info") + assert session1_logging.should_notify?("error") + + # Session 2 (debug level) should notify for both "info" and "debug". + session2_logging = transport.instance_variable_get(:@sessions)[session2][:server_session].logging_message_notification + assert session2_logging.should_notify?("info") + assert session2_logging.should_notify?("debug") end private diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 7d88c81..6fe7ea6 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -6,18 +6,18 @@ module MCP class ServerContextTest < ActiveSupport::TestCase test "ServerContext delegates method calls to the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_equal "test_user", server_context[:user] end test "ServerContext respond_to? returns true for methods on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_respond_to server_context, :[] assert_respond_to server_context, :keys @@ -25,18 +25,18 @@ module MCP test "ServerContext respond_to? returns false for methods not on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) refute_respond_to server_context, :nonexistent_method end test "ServerContext raises NoMethodError for methods not on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_raises(NoMethodError) { server_context.nonexistent_method } end @@ -46,9 +46,9 @@ module MCP def context.custom_method "custom_value" end - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_equal "custom_value", server_context.custom_method end @@ -57,7 +57,7 @@ module MCP progress = mock progress.expects(:report).with(50, total: 100, message: nil).once - server_context = ServerContext.new(nil, progress: progress) + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) server_context.report_progress(50, total: 100) end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index b4388aa..82bb9bd 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -175,5 +175,23 @@ module MCP assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method] assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method] end + + test "server.notify_log_message works after logging/setLevel via session" do + session = ServerSession.new(server: @server, transport: @mock_transport) + + # Client sends logging/setLevel through session. + @server.handle( + { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } }, + session: session, + ) + + # Server-level broadcast should still work because logging level + # is stored on both the session and the server. + @server.notify_log_message(data: "broadcast log", level: "info") + + log_notifications = @mock_transport.notifications.select { |n| n[:method] == Methods::NOTIFICATIONS_MESSAGE } + assert_equal 1, log_notifications.size + assert_equal "broadcast log", log_notifications.first[:params]["data"] + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 012a1de..f452948 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -206,6 +206,20 @@ module MCP assert_instrumentation_data({ method: "ping" }) end + test "unsupported method instrumentation includes client from session" do + session = ServerSession.new(server: @server, transport: mock) + session.store_client_info(client: { name: "session-client", version: "1.0" }) + + request = { + jsonrpc: "2.0", + method: "does/not/exist", + id: 1, + } + + @server.handle(request, session: session) + assert_instrumentation_data({ method: "unsupported_method", client: { name: "session-client", version: "1.0" } }) + end + test "#handle returns nil for notification requests" do request = { jsonrpc: "2.0", From 49fc501abc12ea3dd5bcd9ad134d1ad31b601996 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 16 Mar 2026 19:11:57 +0900 Subject: [PATCH 008/247] Support session expiry controls for `StreamableHTTPTransport` ## Motivation and Context The MCP specification recommends expiring session IDs to reduce session hijacking risks: https://modelcontextprotocol.io/specification/latest/basic/security_best_practices#session-hijacking Several other SDKs (Go, C#, PHP, Python) already implement session expiry controls, but the Ruby SDK did not. Sessions persisted indefinitely unless explicitly deleted or a stream error occurred, leaving abandoned sessions to accumulate in memory. This adds a `session_idle_timeout:` option to `StreamableHTTPTransport#initialize`. When set, sessions that receive no HTTP requests for the specified duration (in seconds) are automatically expired. Expired sessions return 404 on subsequent requests (GET and POST), matching the MCP specification's behavior for terminated sessions. Each request resets the idle timer, so actively used sessions are not interrupted. A background reaper thread periodically cleans up expired sessions to handle orphaned sessions that receive no further requests. The reaper only starts when `session_idle_timeout` is configured. The default is `nil` (no expiry) for backward compatibility, consistent with the Python SDK's approach. The Python SDK recommends 1800 seconds (30 minutes) for most deployments: https://github.com/modelcontextprotocol/python-sdk/pull/2022 Resolves #265. ## How Has This Been Tested? Added tests for session expiry, idle timeout reset, reaper thread lifecycle, input validation, and default behavior in `streamable_http_transport_test.rb`. All existing tests continue to pass. ## Breaking Change None. The default value of `session_idle_timeout` is `nil`, which preserves the existing behavior of sessions never expiring. The new `last_active_at` field in the internal session hash is not part of the public API. Existing code that instantiates `StreamableHTTPTransport.new(server)` or `StreamableHTTPTransport.new(server, stateless: true)` continues to work without changes. --- README.md | 8 + .../transports/streamable_http_transport.rb | 104 +++++- .../streamable_http_transport_test.rb | 342 ++++++++++++++++++ 3 files changed, 445 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9e8a096..febce0f 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,14 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) ``` +By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds). +When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up: + +```ruby +# Session timeout of 30 minutes +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) +``` + ### Unsupported Features (to be implemented in future versions) - Resource subscriptions diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 253f333..eb3ebb9 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -8,18 +8,30 @@ module MCP class Server module Transports class StreamableHTTPTransport < Transport - def initialize(server, stateless: false) + def initialize(server, stateless: false, session_idle_timeout: nil) super(server) - # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`. + # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @mutex = Mutex.new @stateless = stateless + @session_idle_timeout = session_idle_timeout + + if @session_idle_timeout + if @stateless + raise ArgumentError, "session_idle_timeout is not supported in stateless mode." + elsif @session_idle_timeout <= 0 + raise ArgumentError, "session_idle_timeout must be a positive number." + end + end + + start_reaper_thread if @session_idle_timeout end REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze + SESSION_REAP_INTERVAL = 60 def handle_request(request) case request.env["REQUEST_METHOD"] @@ -35,6 +47,9 @@ module MCP end def close + @reaper_thread&.kill + @reaper_thread = nil + @mutex.synchronize do @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) } end @@ -56,6 +71,11 @@ module MCP session = @sessions[session_id] return false unless session && session[:stream] + if session_expired?(session) + cleanup_session_unsafe(session_id) + return false + end + begin send_to_stream(session[:stream], notification) true @@ -75,6 +95,11 @@ module MCP @sessions.each do |sid, session| next unless session[:stream] + if session_expired?(session) + failed_sessions << sid + next + end + begin send_to_stream(session[:stream], notification) sent_count += 1 @@ -97,6 +122,39 @@ module MCP private + def start_reaper_thread + @reaper_thread = Thread.new do + loop do + sleep(SESSION_REAP_INTERVAL) + reap_expired_sessions + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, error: "Session reaper error") + end + end + end + + def reap_expired_sessions + return unless @session_idle_timeout + + expired_streams = @mutex.synchronize do + @sessions.each_with_object([]) do |(session_id, session), streams| + next unless session_expired?(session) + + streams << session[:stream] if session[:stream] + @sessions.delete(session_id) + end + end + + expired_streams.each do |stream| + # Closing outside the mutex is safe because expired sessions are already + # removed from `@sessions` above, so other threads will not find them + # and will not attempt to close the same stream. + stream.close + rescue + nil + end + end + def send_to_stream(stream, data) message = data.is_a?(String) ? data : data.to_json stream.write("data: #{message}\n\n") @@ -145,7 +203,9 @@ module MCP session_id = extract_session_id(request) return missing_session_id_response unless session_id - return session_not_found_response unless session_exists?(session_id) + + error_response = validate_and_touch_session(session_id) + return error_response if error_response return session_already_connected_response if get_session_stream(session_id) setup_sse_stream(session_id) @@ -242,6 +302,7 @@ module MCP @sessions[session_id] = { stream: nil, server_session: server_session, + last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), } end end @@ -269,13 +330,16 @@ module MCP server_session = nil stream = nil - if session_id && !@stateless - @mutex.synchronize do - session = @sessions[session_id] - return session_not_found_response unless session + unless @stateless + if session_id + error_response = validate_and_touch_session(session_id) + return error_response if error_response - server_session = session[:server_session] - stream = session[:stream] + @mutex.synchronize do + session = @sessions[session_id] + server_session = session[:server_session] if session + stream = session[:stream] if session + end end end @@ -292,6 +356,22 @@ module MCP end end + def validate_and_touch_session(session_id) + @mutex.synchronize do + return session_not_found_response unless (session = @sessions[session_id]) + return unless @session_idle_timeout + + if session_expired?(session) + cleanup_session_unsafe(session_id) + return session_not_found_response + end + + session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + nil + end + def get_session_stream(session_id) @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) } end @@ -397,6 +477,12 @@ module MCP ) raise # Re-raise to exit the keepalive loop end + + def session_expired?(session) + return false unless @session_idle_timeout + + Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout + end 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 2938392..1d49125 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -17,6 +17,10 @@ module MCP @transport = StreamableHTTPTransport.new(@server) end + teardown do + @transport.close + end + test "handles POST request with valid JSON-RPC message" do # First create a session init_request = create_rack_request( @@ -1387,6 +1391,344 @@ module MCP assert_equal([], response[2]) end + test "expired session returns 404 on GET request" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + assert(session_id) + + # Session should now be expired (timeout is 0) + sleep(0.01) + + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = transport.handle_request(get_request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]) + ensure + transport.close + end + + test "expired session returns 404 on POST request" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + # Create a session + 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"] + + # Session should now be expired (timeout is 0) + sleep(0.01) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]) + ensure + transport.close + end + + test "session_idle_timeout: nil disables session expiry" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + + 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"] + + # Make a request - session should still be valid + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "session within timeout period remains valid" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600) + + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "session activity resets the idle timeout" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.5) + + 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"] + + # Send requests every 0.2s to keep the session alive. + # Total elapsed time (~0.6s) exceeds timeout (0.5s), but each request + # resets the idle timer so the session remains valid. + 3.times do + sleep(0.2) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = transport.handle_request(request) + assert_equal(200, response[0]) + end + ensure + transport.close + end + + test "reaper thread cleans up expired sessions" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + 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"] + assert(session_id) + + # Wait for session to expire + sleep(0.02) + + # Manually trigger reaper since the background thread runs on 60s interval + transport.send(:reap_expired_sessions) + + # Session should have been reaped + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = transport.handle_request(get_request) + assert_equal(404, response[0]) + ensure + transport.close + end + + test "reaper thread cleans up expired sessions and POST returns 404" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + 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"] + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + transport.send(:reap_expired_sessions) + + # POST to a reaped session should also return 404 + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = transport.handle_request(request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]) + ensure + transport.close + end + + test "close stops the reaper thread" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600) + reaper_thread = transport.instance_variable_get(:@reaper_thread) + assert reaper_thread + assert reaper_thread.alive? + + transport.close + + sleep(0.01) + refute reaper_thread.alive? + assert_nil transport.instance_variable_get(:@reaper_thread) + end + + test "reaper thread is not started when session_idle_timeout is nil" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "default session_idle_timeout is nil and sessions do not expire" do + transport = StreamableHTTPTransport.new(@server) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "raises ArgumentError when session_idle_timeout is zero" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, session_idle_timeout: 0) + end + assert_equal("session_idle_timeout must be a positive number.", error.message) + end + + test "raises ArgumentError when session_idle_timeout is negative" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, session_idle_timeout: -1) + end + assert_equal("session_idle_timeout must be a positive number.", error.message) + end + + test "raises ArgumentError when session_idle_timeout is used with stateless mode" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, stateless: true, session_idle_timeout: 3600) + end + assert_equal("session_idle_timeout is not supported in stateless mode.", error.message) + end + + test "expired session does not receive targeted notification" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + 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"] + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + + result = transport.send_notification("test/notify", { message: "hello" }, session_id: session_id) + refute(result) + ensure + transport.close + end + + test "expired session is skipped during broadcast notification" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + 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"] + + # Attach a mock stream to the session + stream = StringIO.new + transport.instance_variable_get(:@sessions)[session_id][:stream] = stream + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + + sent_count = transport.send_notification("test/notify", { message: "hello" }, **{}) + assert_equal(0, sent_count) + ensure + transport.close + end + test "handles POST request with body including JSON-RPC response object and returns with no body" do init_request = create_rack_request( "POST", From b89f6e315d89d0cf0612300aefbd53761d8512cf Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 28 Mar 2026 01:43:12 +0900 Subject: [PATCH 009/247] Remove `Server#notify_progress` broadcast API ## Motivation and Context `Server#notify_progress` broadcasts progress notifications to all connected clients. Progress notifications are tied to a specific request's `progressToken` and have no meaningful broadcast use case. Neither the Python SDK nor the TypeScript SDK provides a server-level progress broadcast capability. The MCP specification requires that progress notifications only reference tokens provided in an active request: > Progress notifications MUST only reference tokens that: > - Were provided in an active request > - Are associated with an in-progress operation Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress Broadcasting progress to all clients does not comply with this requirement, since clients that did not originate the request have no matching `progressToken`. The introduction of `ServerSession` for per-connection state (#275) made this removal possible: `Progress` and `ServerContext` now use `notification_target:` (a `ServerSession`) to route notifications to the originating session only. ### Changes - Removed `Server#notify_progress` entirely. The method is no longer needed as `notification_target` in `call_tool_with_args` now passes `session` directly (nil when no session is available, in which case `Progress#report` and `ServerContext#notify_log_message` are no-ops). - Removed `ServerContext#notify_progress` as well. Progress notifications should only be sent via `server_context.report_progress`, which enforces the correct `progressToken` from the originating request. - Added nil guards to `Progress#report` and `ServerContext#notify_log_message` for when no session is available. - Rewrote progress tests to use `ServerSession#handle` instead of `Server#handle`, reflecting that progress notifications are always session-scoped. - Removed 4 tests that called `server.notify_progress` directly as a public broadcast API. - Removed `notify_progress` from the README.md notification methods list. - Removed the "Server-Side: Direct `notify_progress` Usage" section from the README.md. Progress notifications should be sent via `server_context.report_progress` inside tool handlers, which automatically scopes them to the originating client session. ## Breaking Changes Progress notifications are scoped to a specific request via `progressToken` per the MCP specification, so the broadcast behavior of `Server#notify_progress` was a spec violation. This is treated as a bug fix and is made without a deprecation period. This aligns the Ruby SDK with the Python and TypeScript SDKs, neither of which provides a server-level progress broadcast API. `Server#notify_progress` and `ServerContext#notify_progress` are no longer available. Users should use `server_context.report_progress` inside tool handlers instead, which provides session-scoped delivery with the correct `progressToken`. This feature was only recently introduced in mcp 0.9.0: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.9.0 So an early release should help limit its impact on adoption. --- README.md | 15 ------- lib/mcp/progress.rb | 1 + lib/mcp/server.rb | 24 ++-------- lib/mcp/server_context.rb | 12 +---- test/mcp/progress_test.rb | 14 ++++-- test/mcp/server_context_test.rb | 7 +++ test/mcp/server_progress_test.rb | 75 ++++++-------------------------- 7 files changed, 39 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 9e8a096..d63b22f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ The server provides the following notification methods: - `notify_tools_list_changed` - Send a notification when the tools list changes - `notify_prompts_list_changed` - Send a notification when the prompts list changes - `notify_resources_list_changed` - Send a notification when the resources list changes -- `notify_progress` - Send a progress notification for long-running operations - `notify_log_message` - Send a structured logging notification message #### Session Scoping @@ -178,24 +177,10 @@ The `server_context.report_progress` method accepts: - `total:` (optional) — total expected value, so clients can display a percentage - `message:` (optional) — human-readable status message -#### Server-Side: Direct `notify_progress` Usage - -You can also call `notify_progress` directly on the server instance: - -```ruby -server.notify_progress( - progress_token: "token-123", - progress: 50, - total: 100, # optional - message: "halfway" # optional -) -``` - **Key Features:** - Tools report progress via `server_context.report_progress` - `report_progress` is a no-op when no `progressToken` was provided by the client -- `notify_progress` is a no-op when no transport is configured - Supports both numeric and string progress tokens ### Logging diff --git a/lib/mcp/progress.rb b/lib/mcp/progress.rb index 7c36a94..8843a0d 100644 --- a/lib/mcp/progress.rb +++ b/lib/mcp/progress.rb @@ -9,6 +9,7 @@ module MCP def report(progress, total: nil, message: nil) return unless @progress_token + return unless @notification_target @notification_target.notify_progress( progress_token: @progress_token, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index ffcce58..0f7fc6f 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -116,7 +116,7 @@ module MCP # @param request [Hash] A parsed JSON-RPC request. # @param session [ServerSession, nil] Per-connection session. Passed by # `ServerSession#handle` for session-scoped notification delivery. - # When `nil`, notifications broadcast to all sessions. + # When `nil`, progress and logging notifications from tool handlers are silently skipped. # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. def handle(request, session: nil) JsonRpcHandler.handle(request) do |method| @@ -129,7 +129,7 @@ module MCP # @param request [String] A JSON-RPC request as a JSON string. # @param session [ServerSession, nil] Per-connection session. Passed by # `ServerSession#handle_json` for session-scoped notification delivery. - # When `nil`, notifications broadcast to all sessions. + # When `nil`, progress and logging notifications from tool handlers are silently skipped. # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. def handle_json(request, session: nil) JsonRpcHandler.handle_json(request) do |method| @@ -186,21 +186,6 @@ module MCP report_exception(e, { notification: "resources_list_changed" }) end - def notify_progress(progress_token:, progress:, total: nil, message: nil) - return unless @transport - - params = { - "progressToken" => progress_token, - "progress" => progress, - "total" => total, - "message" => message, - }.compact - - @transport.send_notification(Methods::NOTIFICATIONS_PROGRESS, params) - rescue => e - report_exception(e, notification: "progress") - end - def notify_log_message(data:, level:, logger: nil) return unless @transport return unless logging_message_notification&.should_notify?(level) @@ -524,9 +509,8 @@ module MCP args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) - notification_target = session || self - progress = Progress.new(notification_target: notification_target, progress_token: progress_token) - server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + progress = Progress.new(notification_target: session, progress_token: progress_token) + server_context = ServerContext.new(context, progress: progress, notification_target: session) tool.call(**args, server_context: server_context).to_h else tool.call(**args).to_h diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 458d2cd..7d1f2d9 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -18,22 +18,14 @@ module MCP @progress.report(progress, total: total, message: message) end - # Sends a progress notification scoped to the originating session. - # - # @param progress_token [String, Integer] The token identifying the operation. - # @param progress [Numeric] Current progress value. - # @param total [Numeric, nil] Total expected value. - # @param message [String, nil] Human-readable status message. - def notify_progress(progress_token:, progress:, total: nil, message: nil) - @notification_target.notify_progress(progress_token: progress_token, progress: progress, total: total, message: message) - end - # Sends a log message notification scoped to the originating session. # # @param data [Object] The log data to send. # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`). # @param logger [String, nil] Logger name. def notify_log_message(data:, level:, logger: nil) + return unless @notification_target + @notification_target.notify_log_message(data: data, level: level, logger: logger) end diff --git a/test/mcp/progress_test.rb b/test/mcp/progress_test.rb index a0349b5..491245b 100644 --- a/test/mcp/progress_test.rb +++ b/test/mcp/progress_test.rb @@ -27,17 +27,25 @@ module MCP @server = Server.new(name: "test_server") @transport = MockTransport.new(@server) @server.transport = @transport + @session = ServerSession.new(server: @server, transport: @transport) end test "#report is a no-op when progress_token is nil" do - progress = Progress.new(notification_target: @server, progress_token: nil) + progress = Progress.new(notification_target: @session, progress_token: nil) + progress.report(50, total: 100, message: "halfway") + + assert_equal 0, @transport.notifications.size + end + + test "#report is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: "token-1") progress.report(50, total: 100, message: "halfway") assert_equal 0, @transport.notifications.size end test "#report sends notification when progress_token is present" do - progress = Progress.new(notification_target: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @session, progress_token: "token-1") progress.report(50, total: 100, message: "halfway") assert_equal 1, @transport.notifications.size @@ -50,7 +58,7 @@ module MCP end test "#report omits total and message when not provided" do - progress = Progress.new(notification_target: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @session, progress_token: "token-1") progress.report(50) assert_equal 1, @transport.notifications.size diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 6fe7ea6..159dae8 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -61,6 +61,13 @@ module MCP server_context.report_progress(50, total: 100) end + test "ServerContext#notify_log_message is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil) + + assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } + end + # Tool without server_context parameter class SimpleToolWithoutContext < Tool tool_name "simple_without_context" diff --git a/test/mcp/server_progress_test.rb b/test/mcp/server_progress_test.rb index cf066fa..f31dfaa 100644 --- a/test/mcp/server_progress_test.rb +++ b/test/mcp/server_progress_test.rb @@ -90,59 +90,7 @@ module MCP @mock_transport = MockTransport.new(@server) @server.transport = @mock_transport - end - - test "#notify_progress sends correct notification through transport with all params" do - @server.notify_progress(progress_token: "token-1", progress: 50, total: 100, message: "halfway") - - assert_equal 1, @mock_transport.notifications.size - notification = @mock_transport.notifications.first - assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] - assert_equal "token-1", notification[:params]["progressToken"] - assert_equal 50, notification[:params]["progress"] - assert_equal 100, notification[:params]["total"] - assert_equal "halfway", notification[:params]["message"] - end - - test "#notify_progress omits total and message when nil" do - @server.notify_progress(progress_token: "token-1", progress: 50) - - assert_equal 1, @mock_transport.notifications.size - notification = @mock_transport.notifications.first - assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] - assert_equal "token-1", notification[:params]["progressToken"] - assert_equal 50, notification[:params]["progress"] - refute notification[:params].key?("total") - refute notification[:params].key?("message") - end - - test "#notify_progress does nothing without transport" do - server_without_transport = Server.new(name: "test_server") - - assert_nothing_raised do - server_without_transport.notify_progress(progress_token: "token-1", progress: 50) - end - end - - test "#notify_progress handles transport errors gracefully" do - error_transport = Class.new(MockTransport) do - def send_notification(method, params = nil) - raise StandardError, "Transport error" - end - end.new(@server) - - @server.transport = error_transport - - @server.configuration.exception_reporter.expects(:call).once.with do |exception, context| - assert_kind_of StandardError, exception - assert_equal "Transport error", exception.message - assert_equal({ notification: "progress" }, context) - true - end - - assert_nothing_raised do - @server.notify_progress(progress_token: "token-1", progress: 50) - end + @session = ServerSession.new(server: @server, transport: @mock_transport) end test "tool with progress parameter receives Progress instance and sends notifications via _meta.progressToken" do @@ -157,7 +105,7 @@ module MCP }, } - response = @server.handle(request) + response = @session.handle(request) assert response[:result] assert_equal "ToolWithProgress: Hello", response[:result][:content][0][:text] @@ -190,7 +138,7 @@ module MCP }, } - response = @server.handle(request) + response = @session.handle(request) assert response[:result] assert_equal "SimpleToolWithoutProgress: Hello", response[:result][:content][0][:text] @@ -215,6 +163,7 @@ module MCP server_context: { user: "test" }, ) server.transport = @mock_transport + session = ServerSession.new(server: server, transport: @mock_transport) request = { jsonrpc: "2.0", @@ -226,7 +175,7 @@ module MCP }, } - server.handle(request) + session.handle(request) assert_instance_of ServerContext, received_context assert_nothing_raised { received_context.report_progress(50) } @@ -245,7 +194,7 @@ module MCP }, } - response = @server.handle(request) + response = @session.handle(request) assert response[:result] assert_equal "ToolWithContextAndProgress: Hello context=test_user", response[:result][:content][0][:text] @@ -269,7 +218,7 @@ module MCP }, } - response = @server.handle(request) + response = @session.handle(request) assert response[:result] assert_equal "ToolWithKwargs: progress=present", response[:result][:content][0][:text] @@ -290,6 +239,8 @@ module MCP Tool::Response.new([{ type: "text", text: "block_tool done" }]) end + session = ServerSession.new(server: server, transport: @mock_transport) + request = { jsonrpc: "2.0", id: 1, @@ -301,7 +252,7 @@ module MCP }, } - response = server.handle(request) + response = session.handle(request) assert response[:result] assert_equal "block_tool done", response[:result][:content][0][:text] @@ -325,6 +276,8 @@ module MCP Tool::Response.new([{ type: "text", text: "done" }]) end + session = ServerSession.new(server: server, transport: @mock_transport) + request = { jsonrpc: "2.0", id: 1, @@ -336,7 +289,7 @@ module MCP }, } - server.handle(request) + session.handle(request) assert_equal 5, @mock_transport.notifications.size @mock_transport.notifications.each_with_index do |n, i| @@ -358,7 +311,7 @@ module MCP } # Should not raise and should return nil (notification, no id). - result = @server.handle(request) + result = @session.handle(request) assert_nil result end end From 73b018c1e169e5d24cb07c44eccbf0a3f9b26b79 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 30 Mar 2026 05:47:29 +0900 Subject: [PATCH 010/247] Release 0.10.0 This release focuses primarily on changes related to the internal session architecture. It also removes some publicly exposed methods that are effectively obsolete. --- CHANGELOG.md | 19 +++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fa070..fcc8b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] - 2026-03-30 + +### Added + +- Session expiry controls for `StreamableHTTPTransport` via `session_idle_timeout:` option (#268) + +### Changed + +- `ServerSession` for per-connection state (#275) + +### Removed + +- Remove `Server#notify_progress` broadcast API (#276) +- Remove undocumented handler override methods (#270) + +### Fixed + +- Reject POST requests without session ID in stateful mode (#274) + ## [0.9.2] - 2026-03-27 ### Fixed diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 3a5b450..c44c5ce 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.9.2" + VERSION = "0.10.0" end From 5c51f7dde80322edafeaf44a9b33ea2a35bd4337 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 30 Mar 2026 13:13:16 +0900 Subject: [PATCH 011/247] [Doc] Add CNAME file to preserve custom domain across deployments Accessing https://ruby.sdk.modelcontextprotocol.io/ is expected to show the content of https://modelcontextprotocol.github.io/ruby-sdk/, but it currently does not. This change fixes that issue. The `generate-gh-pages.sh` script cleans all files from the gh-pages branch root before copying from `docs/`. This removes the CNAME file that GitHub Pages uses for the custom domain, causing `ruby.sdk.modelcontextprotocol.io` to break on every release. https://github.com/modelcontextprotocol/ruby-sdk/blob/v0.10.0/bin/generate-gh-pages.sh#L91-L93 By placing the CNAME file in `docs/`, it will be automatically copied to `gh-pages` during each deployment. https://github.com/modelcontextprotocol/ruby-sdk/blob/v0.10.0/bin/generate-gh-pages.sh#L95-L97 The following is a PR related to the configuration of ruby.sdk.modelcontextprotocol.io. - https://github.com/modelcontextprotocol/ruby-sdk/pull/181 - https://github.com/modelcontextprotocol/dns/pull/14 - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2486 --- docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..f49a17f --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +ruby.sdk.modelcontextprotocol.io From 3a68937bb7f5fdb115c09dcf6427ef0fcdfc8b79 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 31 Mar 2026 03:46:25 +0900 Subject: [PATCH 012/247] Use `symbolize_names: true` in `JSON.parse` calls Replace custom `deep_transform_keys` in `Tool::Schema` and string key access in `StreamableHTTPTransport` with the `symbolize_names: true` option of `JSON.parse`. This aligns `StreamableHTTPTransport` with `StdioTransport`, which already uses `symbolize_names: true`. --- .../transports/streamable_http_transport.rb | 8 ++++---- lib/mcp/tool/schema.rb | 15 +-------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index eb3ebb9..4e01458 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -176,7 +176,7 @@ module MCP body = parse_request_body(body_string) return body unless body.is_a?(Hash) # Error response - if body["method"] == "initialize" + if body[:method] == "initialize" handle_initialization(body_string, body) else return missing_session_id_response if !@stateless && !session_id @@ -277,17 +277,17 @@ module MCP end def parse_request_body(body_string) - JSON.parse(body_string) + JSON.parse(body_string, symbolize_names: true) rescue JSON::ParserError, TypeError [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end def notification?(body) - !body["id"] && !!body["method"] + !body[:id] && !!body[:method] end def response?(body) - !!body["id"] && !body["method"] + !!body[:id] && !body[:method] end def handle_initialization(body_string, body) diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb index 0b908f3..de6d4d7 100644 --- a/lib/mcp/tool/schema.rb +++ b/lib/mcp/tool/schema.rb @@ -8,7 +8,7 @@ module MCP attr_reader :schema def initialize(schema = {}) - @schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym) + @schema = JSON.parse(JSON.dump(schema), symbolize_names: true) @schema[:type] ||= "object" validate_schema! end @@ -27,19 +27,6 @@ module MCP JSON::Validator.fully_validate(to_h, data) end - def deep_transform_keys(schema, &block) - case schema - when Hash - schema.each_with_object({}) do |(key, value), result| - result[yield(key)] = deep_transform_keys(value, &block) - end - when Array - schema.map { |e| deep_transform_keys(e, &block) } - else - schema - end - end - def validate_schema! schema = to_h gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path) From 760346818e4ecad78e6627e6c34d075160591d53 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 31 Mar 2026 04:29:27 +0900 Subject: [PATCH 013/247] Join server threads before closing IO pipes in stdio client tests Background threads simulating server responses could race with the `ensure` block that closes IO pipes, intermittently producing this warning depending on thread scheduling: ```console # terminated with exception (report_on_exception is true): test/mcp/client/stdio_test.rb:55:in `write': stream closed in another thread (IOError) ``` Store each background thread in a `server_thread` variable and call `server_thread&.join(1)` at the top of each `ensure` block to wait for the thread to finish before closing pipes. --- test/mcp/client/stdio_test.rb | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 5a5a97c..b177f6a 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -25,7 +25,7 @@ module MCP } # Simulate server responses: initialize response, then tools/list response - Thread.new do + server_thread = Thread.new do # Read and respond to initialize request init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -62,6 +62,7 @@ module MCP assert_equal(1, response.dig("result", "tools").size) assert_equal("test_tool", response.dig("result", "tools", 0, "name")) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -85,7 +86,7 @@ module MCP received_methods = [] - Thread.new do + server_thread = Thread.new do # Read initialize request init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -126,6 +127,7 @@ module MCP assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -147,7 +149,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Handle initialize handshake init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -187,6 +189,7 @@ module MCP assert_equal("test-id", response["id"]) assert_equal([], response.dig("result", "tools")) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -242,7 +245,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Handle initialize handshake init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -272,6 +275,7 @@ module MCP assert_equal("Server process closed stdout unexpectedly", error.message) assert_equal(:internal_error, error.error_type) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -330,7 +334,7 @@ module MCP received_methods = [] - Thread.new do + server_thread = Thread.new do # First call: initialize handshake init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -384,6 +388,7 @@ module MCP received_methods, ) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -415,7 +420,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Handle initialize handshake init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -447,6 +452,7 @@ module MCP assert_equal(:internal_error, error.error_type) assert_instance_of(JSON::ParserError, error.original_error) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -468,7 +474,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Read initialize request and return an error init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -487,6 +493,7 @@ module MCP assert_equal("Server initialization failed: Invalid Request", error.message) assert_equal(:internal_error, error.error_type) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -534,7 +541,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Handle initialize handshake init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -563,6 +570,7 @@ module MCP assert_equal("Timed out waiting for server response", error.message) assert_equal(:internal_error, error.error_type) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close @@ -689,7 +697,7 @@ module MCP method: "tools/list", } - Thread.new do + server_thread = Thread.new do # Read initialize request and return a response without result init_line = stdin_read.gets init_request = JSON.parse(init_line) @@ -707,6 +715,7 @@ module MCP assert_equal("Server initialization failed: missing result in response", error.message) assert_equal(:internal_error, error.error_type) ensure + server_thread.join stdin_read.close stdin_write.close stdout_read.close From 590feb13a57919d8e0a019932cd4631af525031f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 31 Mar 2026 12:01:18 +0900 Subject: [PATCH 014/247] Fix client methods silently swallowing JSON-RPC error responses ## Motivation and Context Client methods (`tools`, `resources`, `resource_templates`, `prompts`, `read_resource`, `get_prompt`, `call_tool`) silently swallowed JSON-RPC error responses by falling back to empty defaults. This did not conform to JSON-RPC 2.0, which clearly distinguishes error responses from success responses. Both the Python SDK (`MCPError`) and TypeScript SDK (`ProtocolError`) raise exceptions on error responses. The Ruby SDK was the only one silently discarding them. Introduces a common `request` method that handles JSON-RPC request construction and error checking in one place, replacing the duplicated `transport.send_request` calls across all client methods. ## How Has This Been Tested? Added tests for each affected method verifying that `ServerError` is raised with the correct `code`, `message`, and `data` attributes. ## Breaking Changes The previous behavior of silently returning empty defaults on server errors did not conform to JSON-RPC 2.0. Error responses should be surfaced to callers, not swallowed. Callers that relied on the silent fallback behavior will need to rescue `MCP::Client::ServerError`. --- lib/mcp/client.rb | 96 ++++++++++++++++++++--------------------- test/mcp/client_test.rb | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 49 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 6437dd6..7c89bcb 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -6,6 +6,27 @@ require_relative "client/tool" module MCP class Client + class ServerError < StandardError + attr_reader :code, :data + + def initialize(message, code:, data: nil) + super(message) + @code = code + @data = data + end + end + + class RequestHandlerError < StandardError + attr_reader :error_type, :original_error, :request + + def initialize(message, request, error_type: :internal_error, original_error: nil) + super(message) + @request = request + @error_type = error_type + @original_error = original_error + end + end + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. @@ -33,11 +54,7 @@ module MCP # puts tool.name # end def tools - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "tools/list", - }) + response = request(method: "tools/list") response.dig("result", "tools")&.map do |tool| Tool.new( @@ -53,11 +70,7 @@ module MCP # # @return [Array] An array of available resources. def resources - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/list", - }) + response = request(method: "resources/list") response.dig("result", "resources") || [] end @@ -67,11 +80,7 @@ module MCP # # @return [Array] An array of available resource templates. def resource_templates - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/templates/list", - }) + response = request(method: "resources/templates/list") response.dig("result", "resourceTemplates") || [] end @@ -81,11 +90,7 @@ module MCP # # @return [Array] An array of available prompts. def prompts - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "prompts/list", - }) + response = request(method: "prompts/list") response.dig("result", "prompts") || [] end @@ -119,12 +124,7 @@ module MCP params[:_meta] = { progressToken: progress_token } end - transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "tools/call", - params: params, - }) + request(method: "tools/call", params: params) end # Reads a resource from the server by URI and returns the contents. @@ -132,12 +132,7 @@ module MCP # @param uri [String] The URI of the resource to read. # @return [Array] An array of resource contents (text or blob). def read_resource(uri:) - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/read", - params: { uri: uri }, - }) + response = request(method: "resources/read", params: { uri: uri }) response.dig("result", "contents") || [] end @@ -147,31 +142,34 @@ module MCP # @param name [String] The name of the prompt to get. # @return [Hash] A hash containing the prompt details. def get_prompt(name:) - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "prompts/get", - params: { name: name }, - }) + response = request(method: "prompts/get", params: { name: name }) response.fetch("result", {}) end private + def request(method:, params: nil) + request_body = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: request_id, + method: method, + } + request_body[:params] = params if params + + response = transport.send_request(request: request_body) + + # Guard with `is_a?(Hash)` because custom transports may return non-Hash values. + if response.is_a?(Hash) && response.key?("error") + error = response["error"] + raise ServerError.new(error["message"], code: error["code"], data: error["data"]) + end + + response + end + def request_id SecureRandom.uuid end - - class RequestHandlerError < StandardError - attr_reader :error_type, :original_error, :request - - def initialize(message, request, error_type: :internal_error, original_error: nil) - super(message) - @request = request - @error_type = error_type - @original_error = original_error - end - end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index bf83542..5495b15 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -369,5 +369,92 @@ module MCP client = Client.new(transport: transport) client.call_tool(tool: tool, arguments: arguments) end + + def test_tools_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.tools } + assert_equal(-32_601, error.code) + assert_equal("Method not found", error.message) + end + + def test_resources_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.resources } + assert_equal(-32_602, error.code) + end + + def test_read_resource_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") } + end + + def test_get_prompt_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.get_prompt(name: "missing") } + end + + def test_prompts_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.prompts } + end + + def test_resource_templates_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.resource_templates } + end + + def test_call_tool_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") } + assert_equal(-32_602, error.code) + end + + def test_server_error_includes_data_field + transport = mock + mock_response = { + "error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.tools } + assert_equal("extra details", error.data) + end end end From 0141d487fcfeb6c424ca53a621c5c328c46068f8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 28 Mar 2026 19:48:05 +0900 Subject: [PATCH 015/247] Add CLAUDE.md referencing AGENTS.md CLAUDE.md delegates to AGENTS.md via the `@AGENTS.md` directive, avoiding duplication of project guidelines. --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 8dd0379f16c27e2dcb28a0c4ec6903e8654ee1c0 Mon Sep 17 00:00:00 2001 From: Sarah Pickett Date: Wed, 1 Apr 2026 11:08:05 -0400 Subject: [PATCH 016/247] fix: return protocol errors for invalid arguments and server errors Per the MCP spec, invalid arguments and server errors are protocol errors (JSON-RPC error responses), not tool execution errors (isError: true). PR #165 moved all error cases to isError: true, and PR #231 only restored protocol errors for unknown tools. This fixes the remaining two cases: invalid arguments (-32602) and server errors (-32603). Ref: https://modelcontextprotocol.io/specification/2024-11-05/server/tools#error-handling --- lib/mcp/server.rb | 22 +++++---- test/mcp/server_context_test.rb | 10 ++-- test/mcp/server_test.rb | 86 ++++++++++++++++----------------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 0f7fc6f..827ac90 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -316,13 +316,12 @@ module MCP add_instrumentation_data(client: client) if client result + rescue RequestHandlerError => e + report_exception(e.original_error || e, { request: request }) + add_instrumentation_data(error: e.error_type) + raise e rescue => e report_exception(e, { request: request }) - if e.is_a?(RequestHandlerError) - add_instrumentation_data(error: e.error_type) - raise e - end - add_instrumentation_data(error: :internal_error) raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) end @@ -421,7 +420,7 @@ module MCP add_instrumentation_data(error: :missing_required_arguments) missing = tool.input_schema.missing_required_arguments(arguments).join(", ") - return error_tool_response("Missing required arguments: #{missing}") + raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params) end if configuration.validate_tool_call_arguments && tool.input_schema @@ -430,7 +429,7 @@ module MCP rescue Tool::InputSchema::ValidationError => e add_instrumentation_data(error: :invalid_schema) - return error_tool_response(e.message) + raise RequestHandlerError.new(e.message, request, error_type: :invalid_params) end end @@ -440,9 +439,12 @@ module MCP rescue RequestHandlerError raise rescue => e - report_exception(e, request: request) - - error_tool_response("Internal error calling tool #{tool_name}: #{e.message}") + raise RequestHandlerError.new( + "Internal error calling tool #{tool_name}: #{e.message}", + request, + error_type: :internal_error, + original_error: e, + ) end def list_prompts(request) diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 159dae8..dd96796 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -199,7 +199,7 @@ module MCP response[:result][:content][0][:content] end - test "tool with required server_context fails when server has no context" do + test "tool with required server_context returns protocol error in JSON-RPC format when server has no context" do server_no_context = Server.new( name: "test_server_no_context", tools: [ToolWithRequiredContext], @@ -217,10 +217,10 @@ module MCP response = server_no_context.handle(request) - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_equal "text", response[:result][:content][0][:type] - assert_match(/Internal error calling tool tool_with_required_context: /, response[:result][:content][0][:text]) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool tool_with_required_context: /, response[:error][:data]) end test "call_tool_with_args correctly detects server_context parameter presence" do diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index f452948..ac86726 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -312,7 +312,7 @@ module MCP assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end - test "#handle tools/call returns error response with isError true if required tool arguments are missing" do + test "#handle tools/call returns protocol error in JSON-RPC format if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", title: "Test tool", @@ -336,10 +336,10 @@ module MCP response = server.handle(request) - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_equal "text", response[:result][:content][0][:type] - assert_equal "Missing required arguments: message", response[:result][:content][0][:text] + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Missing required arguments: message" end test "#handle_json tools/call executes tool and returns result" do @@ -407,17 +407,7 @@ module MCP assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result]) end - test "#handle tools/call returns error response with isError true if the tool raises an error" do - @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| - assert_not_nil exception - assert_equal( - { - request: { name: "tool_that_raises", arguments: { message: "test" } }, - }, - server_context, - ) - end - + test "#handle tools/call returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = { jsonrpc: "2.0", method: "tools/call", @@ -428,13 +418,18 @@ module MCP id: 1, } + @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| + refute_kind_of MCP::Server::RequestHandlerError, exception + assert_equal({ request: request }, server_context) + end + response = @server.handle(request) - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_equal "text", response[:result][:content][0][:type] - assert_match(/Internal error calling tool tool_that_raises: /, response[:result][:content][0][:text]) - assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" } }) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end test "registers tools with the same class name in different namespaces" do @@ -475,7 +470,7 @@ module MCP MESSAGE end - test "#handle_json returns error response with isError true if the tool raises an error" do + test "#handle_json returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", @@ -487,14 +482,14 @@ module MCP }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_equal "text", response[:result][:content][0][:type] - assert_match(/Internal error calling tool tool_that_raises: /, response[:result][:content][0][:text]) - assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" } }) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end - test "#handle tools/call returns error response with isError true if input_schema raises an error during validation" do + test "#handle tools/call returns protocol error in JSON-RPC format if input_schema raises an error during validation" do tool = Tool.define( name: "tool_with_faulty_schema", title: "Tool with faulty schema", @@ -518,10 +513,10 @@ module MCP response = server.handle(request) - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_equal "text", response[:result][:content][0][:type] - assert_match(/Internal error calling tool tool_with_faulty_schema: Unexpected schema error/, response[:result][:content][0][:text]) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool tool_with_faulty_schema: Unexpected schema error/, response[:error][:data]) end test "#handle tools/call returns JSON-RPC error for unknown tool" do @@ -1310,7 +1305,7 @@ module MCP refute response[:result].key?(:instructions) end - test "tools/call handles missing arguments field" do + test "tools/call returns protocol error in JSON-RPC format for missing arguments" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: true), @@ -1329,12 +1324,13 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_includes response[:result][:content][0][:text], "Missing required arguments" + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Missing required arguments" end - test "tools/call validates arguments against input schema when validate_tool_call_arguments is true" do + test "tools/call returns protocol error in JSON-RPC format for invalid arguments when validate_tool_call_arguments is true" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: true), @@ -1354,9 +1350,10 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_includes response[:result][:content][0][:text], "Invalid arguments" + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Invalid arguments" end test "tools/call skips argument validation when validate_tool_call_arguments is false" do @@ -1441,7 +1438,7 @@ module MCP assert_equal "OK", response[:result][:content][0][:content] end - test "tools/call disallows additional properties when additionalProperties set to false" do + test "tools/call returns protocol error in JSON-RPC format when additionalProperties set to false" do server = Server.new( tools: [TestToolWithAdditionalPropertiesSetToFalse], configuration: Configuration.new(validate_tool_call_arguments: true), @@ -1464,9 +1461,10 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:error], "Expected no JSON-RPC error" - assert response[:result][:isError] - assert_includes response[:result][:content][0][:text], "Invalid arguments" + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Invalid arguments" end test "#handle completion/complete returns default completion result" do From d3f400f525320529005acad0c0c682a080311c95 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Apr 2026 01:28:29 +0900 Subject: [PATCH 017/247] Use `rescue StandardError` for stream close ## Motivation and Context Replace bare `rescue` with explicit `rescue StandardError` in stream cleanup to clarify intent, with a comment explaining why. In Ruby, `rescue` without an exception class is equivalent to `rescue StandardError`, so this is a readability improvement with no behavior change. ## How Has This Been Tested? Existing tests pass. ## Breaking Changes None. --- lib/mcp/server/transports/streamable_http_transport.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4e01458..c2efc2c 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -150,8 +150,8 @@ module MCP # removed from `@sessions` above, so other threads will not find them # and will not attempt to close the same stream. stream.close - rescue - nil + rescue StandardError + # Ignore close-related errors from already closed/broken streams. end end @@ -239,8 +239,8 @@ module MCP begin session[:stream]&.close - rescue - nil + rescue StandardError + # Ignore close-related errors from already closed/broken streams. end @sessions.delete(session_id) end From 17e9603474417b792af723a3f7f9fb3af69761e0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 20 Mar 2026 08:22:09 +0900 Subject: [PATCH 018/247] Add `extra_rdoc_files` to gemspec This makes `LICENSE` and `README.md` available to RDoc documentation. The files remain included in the gem package because `Gem::Specification#files` automatically merges `extra_rdoc_files`. --- mcp.gemspec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcp.gemspec b/mcp.gemspec index 95627a6..2d8de1c 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -23,7 +23,8 @@ Gem::Specification.new do |spec| spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/mcp" - spec.files = Dir["LICENSE", "README.md", "lib/**/*"] + spec.files = Dir["lib/**/*"] + spec.extra_rdoc_files = ["LICENSE", "README.md"] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } From 88e1f9a8407ad3e4d8cf08617a2b011e0cf660bd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 1 Apr 2026 01:03:23 +0900 Subject: [PATCH 019/247] Support `completion/complete` per MCP specification ## Motivation and Context The MCP specification defines `completion/complete` for providing autocompletion suggestions for prompt arguments and resource template URIs. The Ruby SDK previously had only a no-op handler that returned empty results, with no way for users to define custom completion logic. This aligns the Ruby SDK with the Python and TypeScript SDKs, both of which support user-defined completion handlers. ## How Has This Been Tested? Server tests cover: default handler, custom handler for `ref/prompt` and `ref/resource`, context argument passing, 100-item truncation, and error responses for nonexistent prompts, nonexistent resource templates, and invalid ref types. Client tests cover: request structure, context parameter inclusion, and fallback when result is missing. ## Breaking Changes None. The existing default no-op handler behavior is preserved. The `completion_handler` method is purely additive. The only behavioral change is that `completion/complete` requests now validate that the referenced prompt or resource template exists before calling the handler, returning an `invalid_params` error for unknown references. --- README.md | 50 ++++- conformance/server.rb | 30 +++ lib/mcp/client.rb | 16 ++ lib/mcp/server.rb | 78 +++++++- test/mcp/client_test.rb | 81 ++++++++ test/mcp/server_test.rb | 428 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 681 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 651c2ef..36d6784 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ It implements the Model Context Protocol specification, handling model context r - `resources/list` - Lists all registered resources and their schemas - `resources/read` - Retrieves a specific resource by name - `resources/templates/list` - Lists all registered resource templates and their schemas +- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs ### Custom Methods @@ -183,6 +184,53 @@ The `server_context.report_progress` method accepts: - `report_progress` is a no-op when no `progressToken` was provided by the client - Supports both numeric and string progress tokens +### Completions + +MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), +which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. + +To enable completions, declare the `completions` capability and register a handler: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [CodeReviewPrompt], + resource_templates: [FileTemplate], + capabilities: { completions: {} }, +) + +server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value] + + case ref[:type] + when "ref/prompt" + values = case argument[:name] + when "language" + ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } + else + [] + end + { completion: { values: values, hasMore: false } } + when "ref/resource" + { completion: { values: [], hasMore: false } } + end +end +``` + +The handler receives a `params` hash with: + +- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) +- `argument` - The argument being completed (`{ name: "...", value: "..." }`) +- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) + +The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. +The SDK automatically enforces the 100-item limit per the MCP specification. + +The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. +Requests for unknown references return an error. + ### Logging The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). @@ -298,7 +346,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session ### Unsupported Features (to be implemented in future versions) - Resource subscriptions -- Completions - Elicitation ### Usage @@ -1056,6 +1103,7 @@ This class supports: - Resource reading via the `resources/read` method (`MCP::Client#read_resources`) - Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) - Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) +- Completion requests via the `completion/complete` method (`MCP::Client#complete`) - Automatic JSON-RPC 2.0 message formatting - UUID request ID generation diff --git a/conformance/server.rb b/conformance/server.rb index 9c99f2b..9e5fd5e 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -488,6 +488,7 @@ module Conformance server.server_context = server configure_resources_read_handler(server) + configure_completion_handler(server) end def configure_resources_read_handler(server) @@ -528,6 +529,35 @@ module Conformance end end + def configure_completion_handler(server) + server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value].to_s + + case ref[:type] + when "ref/prompt" + case ref[:name] + when "test_prompt_with_arguments" + candidates = case argument[:name] + when "arg1" + ["value1", "value2", "value3"] + when "arg2" + ["optionA", "optionB", "optionC"] + else + [] + end + values = candidates.select { |v| v.start_with?(value) } + { completion: { values: values, hasMore: false } } + else + { completion: { values: [], hasMore: false } } + end + else + { completion: { values: [], hasMore: false } } + end + end + end + def build_rack_app(transport) mcp_app = proc do |env| request = Rack::Request.new(env) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 7c89bcb..d37b24a 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -147,6 +147,22 @@ module MCP response.fetch("result", {}) end + # Requests completion suggestions from the server for a prompt argument or resource template URI. + # + # @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }` + # or `{ type: "ref/resource", uri: "file:///{path}" }`. + # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`. + # @param context [Hash, nil] Optional context with previously resolved arguments. + # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`. + def complete(ref:, argument:, context: nil) + params = { ref: ref, argument: argument } + params[:context] = context if context + + response = request(method: "completion/complete", params: params) + + response.dig("result", "completion") || { "values" => [], "hasMore" => false } + end + private def request(method:, params: nil) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 827ac90..1b026a1 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -24,6 +24,12 @@ module MCP UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze + DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze + + # Servers return an array of completion values ranked by relevance, with maximum 100 items per response. + # https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results + MAX_COMPLETION_VALUES = 100 + class RequestHandlerError < StandardError attr_reader :error_type attr_reader :original_error @@ -100,12 +106,12 @@ module MCP Methods::PING => ->(_) { {} }, Methods::NOTIFICATIONS_INITIALIZED => ->(_) {}, Methods::NOTIFICATIONS_PROGRESS => ->(_) {}, + Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT }, Methods::LOGGING_SET_LEVEL => method(:configure_logging_level), # No op handlers for currently unsupported methods Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, - Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } }, Methods::ELICITATION_CREATE => ->(_) {}, } @transport = transport @@ -208,6 +214,15 @@ module MCP @handlers[Methods::RESOURCES_READ] = block end + # Sets a custom handler for `completion/complete` requests. + # The block receives the parsed request params and should return completion values. + # + # @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`. + # @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`. + def completion_handler(&block) + @handlers[Methods::COMPLETION_COMPLETE] = block + end + private def validate! @@ -307,6 +322,8 @@ module MCP { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } when Methods::TOOLS_CALL call_tool(params, session: session) + when Methods::COMPLETION_COMPLETE + complete(params) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else @@ -481,6 +498,14 @@ module MCP @resource_templates.map(&:to_h) end + def complete(params) + validate_completion_params!(params) + + result = @handlers[Methods::COMPLETION_COMPLETE].call(params) + + normalize_completion_result(result) + end + def report_exception(exception, server_context = {}) configuration.exception_reporter.call(exception, server_context) end @@ -539,5 +564,56 @@ module MCP server_context end end + + def validate_completion_params!(params) + unless params.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) + end + + ref = params[:ref] + if ref.nil? || ref[:type].nil? + raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params) + end + + argument = params[:argument] + if argument.nil? || argument[:name].nil? || !argument.key?(:value) + raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params) + end + + case ref[:type] + when "ref/prompt" + unless @prompts[ref[:name]] + raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params) + end + when "ref/resource" + uri = ref[:uri] + found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri } + unless found + raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params) + end + else + raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params) + end + end + + def normalize_completion_result(result) + return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash) + + completion = result[:completion] || result["completion"] + return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash) + + values = completion[:values] || completion["values"] || [] + total = completion[:total] || completion["total"] + has_more = completion[:hasMore] || completion["hasMore"] || false + + count = values.length + if count > MAX_COMPLETION_VALUES + has_more = true + total ||= count + values = values.first(MAX_COMPLETION_VALUES) + end + + { completion: { values: values, total: total, hasMore: has_more }.compact } + end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 5495b15..0252e0e 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -456,5 +456,86 @@ module MCP error = assert_raises(Client::ServerError) { client.tools } assert_equal("extra details", error.data) end + + def test_complete_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) } + assert_equal(-32_602, error.code) + end + + def test_complete_sends_request_and_returns_completion_result + transport = mock + mock_response = { + "result" => { + "completion" => { + "values" => ["python", "pytorch"], + "hasMore" => false, + }, + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "completion/complete" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } && + args.dig(:request, :params, :argument) == { name: "language", value: "py" } && + !args.dig(:request, :params).key?(:context) + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "language", value: "py" }, + ) + + assert_equal(["python", "pytorch"], result["values"]) + refute(result["hasMore"]) + end + + def test_complete_includes_context_when_provided + transport = mock + mock_response = { + "result" => { + "completion" => { + "values" => ["flask"], + "hasMore" => false, + }, + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params, :context) == { arguments: { language: "python" } } + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "framework", value: "fla" }, + context: { arguments: { language: "python" } }, + ) + + assert_equal(["flask"], result["values"]) + end + + def test_complete_returns_default_when_result_is_missing + transport = mock + mock_response = { "result" => {} } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + ) + + assert_equal([], result["values"]) + refute(result["hasMore"]) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index ac86726..785e16e 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1468,8 +1468,10 @@ module MCP end test "#handle completion/complete returns default completion result" do + prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", + prompts: [prompt], capabilities: { completions: {} }, ) @@ -1496,6 +1498,432 @@ module MCP ) end + test "#handle completion/complete with custom handler for ref/prompt" do + prompt = Prompt.define( + name: "code_review", + arguments: [Prompt::Argument.new(name: "language", required: true)], + ) {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "language", value: "py" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } }, + }, + response, + ) + end + + test "#handle completion/complete with custom handler for ref/resource" do + template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "file", + ) + server = Server.new( + name: "test_server", + resource_templates: [template], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["file:///src", "file:///spec"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "file:///{path}" }, + argument: { name: "path", value: "s" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["file:///src", "file:///spec"], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete passes context arguments to handler" do + prompt = Prompt.define( + name: "code_review", + arguments: [ + Prompt::Argument.new(name: "language", required: true), + Prompt::Argument.new(name: "framework", required: false), + ], + ) {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + received_params = nil + server.completion_handler do |params| + received_params = params + { completion: { values: ["flask"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "framework", value: "fla" }, + context: { arguments: { language: "python" } }, + }, + }) + + assert_equal({ language: "python" }, received_params.dig(:context, :arguments)) + end + + test "#handle completion/complete truncates values exceeding 100 items" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: (1..150).map(&:to_s), hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + completion = response[:result][:completion] + assert_equal 100, completion[:values].length + assert_equal "1", completion[:values].first + assert_equal "100", completion[:values].last + assert(completion[:hasMore]) + assert_equal 150, completion[:total] + end + + test "#handle completion/complete returns error for nonexistent prompt" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "nonexistent" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for nonexistent resource template" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "unknown://template" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for invalid ref type" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/invalid" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for missing ref" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: {}, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete with custom handler for ref/resource with resource URI" do + resource = Resource.new( + uri: "file:///README.md", + name: "readme", + ) + server = Server.new( + name: "test_server", + resources: [resource], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["file:///README.md"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "file:///README.md" }, + argument: { name: "path", value: "R" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["file:///README.md"], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete returns error for missing argument" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for missing argument value" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns default when handler returns nil" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + nil + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: [], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete with string-keyed handler result" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { "completion" => { "values" => ["alpha", "beta"], "hasMore" => true } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert_equal ["alpha", "beta"], response[:result][:completion][:values] + assert response[:result][:completion][:hasMore] + end + + test "#handle completion/complete returns invalid params for non-Hash params" do + server = Server.new( + name: "test_server", + prompts: [], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: "invalid", + }) + + assert_equal(-32602, response[:error][:code]) + end + + test "#handle completion/complete returns error when completions capability is not declared" do + server = Server.new( + name: "test_server", + prompts: [], + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert response[:error] + assert_includes response[:error][:data], "completions" + end + test "#handle resources/subscribe returns empty result" do server = Server.new( name: "test_server", From 9ebb42a5364fad3f59e814b2026baf7393538eec Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 3 Apr 2026 20:42:59 +0900 Subject: [PATCH 020/247] [Doc] Clarify that `StreamableHTTPTransport` requires a single-process server ## Motivation and Context `StreamableHTTPTransport` stores session and SSE stream state in memory using instance variables (`@sessions`, `@mutex`). This design requires all HTTP requests to be handled within the same process to share state. Process-based servers like Unicorn fork separate worker processes that do not share memory, which breaks session management and SSE connections. This requirement is not obvious from the current documentation. ## How Has This Been Tested? Documentation-only change. ## Breaking Changes None. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 651c2ef..da6f0ef 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,13 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session ### Usage +> [!IMPORTANT] +> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, +> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). +> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. + #### Rails Controller When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming From 66428ace047b0efccfa6a0be2741395d0ede960f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Apr 2026 03:46:00 +0900 Subject: [PATCH 021/247] Close streams outside mutex in session cleanup ## Motivation and Context `cleanup_session_unsafe` was closing streams inside the mutex, which could block all session operations if `stream.close` is slow or blocks (e.g., waiting for network I/O). The original `reap_expired_sessions` avoided this by closing streams outside the mutex, but the other 6 call sites of `cleanup_session_unsafe` did not, creating an inconsistency. This change separates stream closing from session deletion. `cleanup_session_unsafe` now only removes the session from `@sessions` and returns it. All callers close streams outside the mutex via `close_stream_safely`, ensuring consistent behavior and preventing mutex contention from blocking I/O. ## How Has This Been Tested? Added tests that verify streams are closed outside the mutex by using `Mutex#try_lock` inside mock stream close callbacks: - `reap_expired_sessions closes stream outside mutex` - `send_notification closes stream outside mutex on write error` ## Breaking Changes None. This is an internal improvement with no change to public API behavior. --- .../transports/streamable_http_transport.rb | 90 +++++++++++-------- .../streamable_http_transport_test.rb | 66 ++++++++++++++ 2 files changed, 121 insertions(+), 35 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index c2efc2c..14b6906 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -50,8 +50,12 @@ module MCP @reaper_thread&.kill @reaper_thread = nil - @mutex.synchronize do - @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) } + removed_sessions = @mutex.synchronize do + @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) } + end + + removed_sessions.each do |session| + close_stream_safely(session[:stream]) end end @@ -65,15 +69,17 @@ module MCP } notification[:params] = params if params - @mutex.synchronize do + streams_to_close = [] + + result = @mutex.synchronize do if session_id # Send to specific session session = @sessions[session_id] - return false unless session && session[:stream] + next false unless session && session[:stream] if session_expired?(session) - cleanup_session_unsafe(session_id) - return false + cleanup_and_collect_stream(session_id, streams_to_close) + next false end begin @@ -84,7 +90,7 @@ module MCP e, { session_id: session_id, error: "Failed to send notification" }, ) - cleanup_session_unsafe(session_id) + cleanup_and_collect_stream(session_id, streams_to_close) false end else @@ -113,11 +119,17 @@ module MCP end # Clean up failed sessions - failed_sessions.each { |sid| cleanup_session_unsafe(sid) } + failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) } sent_count end end + + streams_to_close.each do |stream| + close_stream_safely(stream) + end + + result end private @@ -136,22 +148,16 @@ module MCP def reap_expired_sessions return unless @session_idle_timeout - expired_streams = @mutex.synchronize do - @sessions.each_with_object([]) do |(session_id, session), streams| - next unless session_expired?(session) + removed_sessions = @mutex.synchronize do + @sessions.each_key.filter_map do |session_id| + next unless session_expired?(@sessions[session_id]) - streams << session[:stream] if session[:stream] - @sessions.delete(session_id) + cleanup_session_unsafe(session_id) end end - expired_streams.each do |stream| - # Closing outside the mutex is safe because expired sessions are already - # removed from `@sessions` above, so other threads will not find them - # and will not attempt to close the same stream. - stream.close - rescue StandardError - # Ignore close-related errors from already closed/broken streams. + removed_sessions.each do |session| + close_stream_safely(session[:stream]) end end @@ -228,23 +234,32 @@ module MCP end def cleanup_session(session_id) - @mutex.synchronize do + session = @mutex.synchronize do cleanup_session_unsafe(session_id) end + + close_stream_safely(session[:stream]) if session end + # Removes a session from `@sessions` and returns it. Does not close the stream. + # Callers must close the stream outside the mutex to avoid holding the lock during + # potentially blocking I/O. def cleanup_session_unsafe(session_id) - session = @sessions[session_id] - return unless session - - begin - session[:stream]&.close - rescue StandardError - # Ignore close-related errors from already closed/broken streams. - end @sessions.delete(session_id) end + def cleanup_and_collect_stream(session_id, streams_to_close) + return unless (removed = cleanup_session_unsafe(session_id)) + + streams_to_close << removed[:stream] + end + + def close_stream_safely(stream) + stream&.close + rescue StandardError + # Ignore close-related errors from already closed/broken streams. + end + def extract_session_id(request) request.env["HTTP_MCP_SESSION_ID"] end @@ -357,19 +372,24 @@ module MCP end def validate_and_touch_session(session_id) - @mutex.synchronize do - return session_not_found_response unless (session = @sessions[session_id]) - return unless @session_idle_timeout + removed = nil + + response = @mutex.synchronize do + next session_not_found_response unless (session = @sessions[session_id]) + next unless @session_idle_timeout if session_expired?(session) - cleanup_session_unsafe(session_id) - return session_not_found_response + removed = cleanup_session_unsafe(session_id) + next session_not_found_response end session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + nil end - nil + close_stream_safely(removed[:stream]) if removed + + response end def get_session_stream(session_id) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 1d49125..d4faa8d 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -857,6 +857,37 @@ module MCP assert_not @transport.instance_variable_get(:@sessions).key?(session_id) end + test "send_notification closes stream outside mutex on write error" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Use a mock stream that verifies mutex is NOT held during close. + mutex = @transport.instance_variable_get(:@mutex) + closed_outside_mutex = false + mock_stream = Object.new + mock_stream.define_singleton_method(:write) { |_data| raise Errno::EPIPE } + mock_stream.define_singleton_method(:close) do + if mutex.try_lock + closed_outside_mutex = true + mutex.unlock + end + end + + @transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + + result = @transport.send_notification("test", { message: "test" }, session_id: session_id) + + refute result + assert closed_outside_mutex, "Stream should be closed outside the mutex" + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + end + test "send_notification broadcast continues when one session raises Errno::ECONNRESET" do # Create two sessions. init_request1 = create_rack_request( @@ -1613,6 +1644,41 @@ module MCP transport.close end + test "reap_expired_sessions closes stream outside mutex" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + 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"] + + # Replace the stream with one that verifies mutex is NOT held during close. + mutex = transport.instance_variable_get(:@mutex) + closed_outside_mutex = false + mock_stream = Object.new + mock_stream.define_singleton_method(:close) do + # If stream.close runs outside the mutex, try_lock succeeds. + if mutex.try_lock + closed_outside_mutex = true + mutex.unlock + end + end + transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + + sleep(0.02) # Wait for session to expire. + + transport.send(:reap_expired_sessions) + + assert(closed_outside_mutex, "Stream should be closed outside the mutex") + assert_empty(transport.instance_variable_get(:@sessions)) + ensure + transport.close + end + test "close stops the reaper thread" do transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600) reaper_thread = transport.instance_variable_get(:@reaper_thread) From da5f9225ac0f97cab46037a08d1a25705d954199 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 29 Mar 2026 16:05:10 +0900 Subject: [PATCH 022/247] Support `sampling/createMessage` per MCP specification ## Motivation and Context The MCP specification defines `sampling/createMessage` to request LLM completions from the client. The Ruby SDK did not yet support server-to-client requests, so the server had no way to ask the client to sample an LLM. This caused the `tools-call-sampling` conformance test to fail. This adds `Server#create_sampling_message` to send a `sampling/createMessage` request to the client via the transport layer. The method validates that the client declared the `sampling` capability during initialization, and optionally checks the `sampling.tools` capability when tools or tool_choice parameters are provided. On the transport side, `Transport#send_request` is introduced as an abstract method, with implementations in both `StdioTransport` and `StreamableHTTPTransport`. Each transport assigns a unique request ID, sends the JSON-RPC request to the client, and blocks until the client responds. Client capabilities are stored per session via `ServerSession`, so validation is scoped to the originating client. `ServerSession#create_sampling_message` uses `build_sampling_params` for capability validation and sends the request directly through the transport with the session ID, following the same pattern as `notify_progress`. `Server#create_sampling_message` is the public API for single-client transports (e.g., StdioTransport). For multi-client transports (e.g., StreamableHTTPTransport), `ServerSession#create_sampling_message` must be used to route requests to the correct client. Tools using `server_context.create_sampling_message` automatically route to the correct session regardless of transport. `StreamableHTTPTransport#send_request` requires `session_id` to prevent broadcasting sampling requests to all connected clients. Missing or invalid session IDs raise explicit errors instead of silently failing. The `include_context` parameter is soft-deprecated in the MCP specification but is included for compatibility with existing clients that declare the `sampling.context` capability. Python and TypeScript SDKs also retain this parameter. Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/sampling ## How Has This Been Tested? Added comprehensive tests in `test/mcp/server_sampling_test.rb` covering required and optional parameters, capability validation, error handling, nil-param omission, per-session capability isolation, and HTTP init capability scoping. Added transport tests in `test/mcp/server/transports/stdio_transport_test.rb` and `test/mcp/server/transports/streamable_http_transport_test.rb` for `send_request` round-trip behavior, error responses, stateless mode rejection, missing session_id, invalid session_id, and no-active-stream error. Conformance: `tools-call-sampling` is removed from expected failures. The conformance server's `TestSampling` tool now calls `server_context.create_sampling_message` instead of returning a stub error. This aligns the Ruby SDK with the MCP specification and other SDK implementations. ## Breaking Changes None for end users. `Transport#send_request` is a new abstract method. `ServerSession#create_sampling_message` is a new method. --- README.md | 159 +++++++ conformance/expected_failures.yml | 4 +- conformance/server.rb | 13 +- lib/mcp/server.rb | 81 +++- lib/mcp/server/transports/stdio_transport.rb | 35 ++ .../transports/streamable_http_transport.rb | 108 ++++- lib/mcp/server_context.rb | 13 + lib/mcp/server_session.rb | 30 +- lib/mcp/transport.rb | 13 + .../server/transports/stdio_transport_test.rb | 241 +++++++++++ .../streamable_http_transport_test.rb | 327 ++++++++++++++ test/mcp/server_context_test.rb | 40 ++ test/mcp/server_sampling_test.rb | 398 ++++++++++++++++++ 13 files changed, 1449 insertions(+), 13 deletions(-) create mode 100644 test/mcp/server_sampling_test.rb diff --git a/README.md b/README.md index a682323..334f021 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports resource registration and retrieval - Supports stdio & Streamable HTTP (including SSE) transports - Supports notifications for list changes (tools, prompts, resources) +- Supports sampling (server-to-client LLM completion requests) ### Supported Methods @@ -51,6 +52,7 @@ It implements the Model Context Protocol specification, handling model context r - `resources/read` - Retrieves a specific resource by name - `resources/templates/list` - Lists all registered resource templates and their schemas - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) ### Custom Methods @@ -103,6 +105,163 @@ end - Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method - Supports the same exception reporting and instrumentation as standard methods +### Sampling + +The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. +This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. + +**Key Concepts:** + +- **Server-to-Client Request**: Unlike typical MCP methods (client→server), sampling is initiated by the server +- **Client Capability**: Clients must declare `sampling` capability during initialization +- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability +- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs + +**Usage Example (Stdio transport):** + +`Server#create_sampling_message` is for single-client transports (e.g., `StdioTransport`). +For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools instead, +which routes the request to the correct client session. + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) +server.transport = transport +``` + +Client must declare sampling capability during initialization. +This happens automatically when the client connects. + +```ruby +result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What is the capital of France?" } } + ], + max_tokens: 100, + system_prompt: "You are a helpful assistant.", + temperature: 0.7 +) +``` + +Result contains the LLM response: + +```ruby +{ + role: "assistant", + content: { type: "text", text: "The capital of France is Paris." }, + model: "claude-3-sonnet-20240307", + stopReason: "endTurn" +} +``` + +**Parameters:** + +Required: + +- `messages:` (Array) - Array of message objects with `role` and `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +**Using Sampling in Tools (works with both Stdio and HTTP transports):** + +Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. +The request is automatically routed to the correct client session. +Set `server.server_context = server` so that `server_context.create_sampling_message` delegates to the server: + +```ruby +class SummarizeTool < MCP::Tool + description "Summarize text using LLM" + input_schema( + properties: { + text: { type: "string" } + }, + required: ["text"] + ) + + def self.call(text:, server_context:) + result = server_context.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } + ], + max_tokens: 500 + ) + + MCP::Tool::Response.new([{ + type: "text", + text: result[:content][:text] + }]) + end +end + +server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) +server.server_context = server +``` + +**Tool Use in Sampling:** + +When tools are provided in a sampling request, the LLM can call them during generation. +The server must handle tool calls and continue the conversation with tool results: + +```ruby +result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What's the weather in Paris?" } } + ], + max_tokens: 1000, + tools: [ + { + name: "get_weather", + description: "Get weather for a city", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"] + } + } + ], + tool_choice: { mode: "auto" } +) + +if result[:stopReason] == "toolUse" + tool_results = result[:content].map do |tool_use| + weather_data = get_weather(tool_use[:input][:city]) + + { + type: "tool_result", + toolUseId: tool_use[:id], + content: [{ type: "text", text: weather_data.to_json }] + } + end + + final_result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What's the weather in Paris?" } }, + { role: "assistant", content: result[:content] }, + { role: "user", content: tool_results } + ], + max_tokens: 1000, + tools: [...] + ) +end +``` + +**Error Handling:** + +- Raises `RuntimeError` if transport is not set +- Raises `RuntimeError` if client does not support `sampling` capability +- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability +- Raises `StandardError` if client returns an error response + ### Notifications The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index c380d27..cb7a3fa 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,7 +1,5 @@ server: - # TODO: Server-to-client requests (sampling/createMessage, elicitation/create) are not implemented. - # `Transport#send_request` does not exist in the current SDK. - - tools-call-sampling + # TODO: Server-to-client requests (elicitation/create) are not implemented. - tools-call-elicitation - elicitation-sep1034-defaults - elicitation-sep1330-enums diff --git a/conformance/server.rb b/conformance/server.rb index 9e5fd5e..4bfac2a 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -156,7 +156,6 @@ module Conformance end end - # TODO: Implement when `Transport` supports server-to-client requests. class TestSampling < MCP::Tool tool_name "test_sampling" description "A tool that requests LLM sampling from the client" @@ -166,11 +165,15 @@ module Conformance ) class << self - def call(prompt:) - MCP::Tool::Response.new( - [MCP::Content::Text.new("Sampling not supported in this SDK version").to_h], - error: true, + def call(prompt:, server_context:) + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, ) + model = result[:model] || "unknown" + text = result.dig(:content, :text) || "" + + MCP::Tool::Response.new([MCP::Content::Text.new("LLM response: #{text} (model: #{model})").to_h]) end end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 1b026a1..484b29b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -54,6 +54,7 @@ module MCP include Instrumentation attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification + attr_reader :client_capabilities def initialize( description: nil, @@ -92,6 +93,7 @@ module MCP validate! @capabilities = capabilities || default_capabilities + @client_capabilities = nil @logging_message_notification = nil @handlers = { @@ -204,6 +206,43 @@ module MCP report_exception(e, { notification: "log_message" }) end + # Sends a `sampling/createMessage` request to the client. + # For single-client transports (e.g., `StdioTransport`). For multi-client transports + # (e.g., `StreamableHTTPTransport`), use `ServerSession#create_sampling_message` instead + # to ensure the request is routed to the correct client. + def create_sampling_message( + messages:, + max_tokens:, + system_prompt: nil, + model_preferences: nil, + include_context: nil, + temperature: nil, + stop_sequences: nil, + metadata: nil, + tools: nil, + tool_choice: nil + ) + unless @transport + raise "Cannot send sampling request without a transport." + end + + params = build_sampling_params( + @client_capabilities, + messages: messages, + max_tokens: max_tokens, + system_prompt: system_prompt, + model_preferences: model_preferences, + include_context: include_context, + temperature: temperature, + stop_sequences: stop_sequences, + metadata: metadata, + tools: tools, + tool_choice: tool_choice, + ) + + @transport.send_request(Methods::SAMPLING_CREATE_MESSAGE, params) + end + # Sets a custom handler for `resources/read` requests. # The block receives the parsed request params and should return resource # contents. The return value is set as the `contents` field of the response. @@ -223,6 +262,45 @@ module MCP @handlers[Methods::COMPLETION_COMPLETE] = block end + def build_sampling_params( + capabilities, + messages:, + max_tokens:, + system_prompt: nil, + model_preferences: nil, + include_context: nil, + temperature: nil, + stop_sequences: nil, + metadata: nil, + tools: nil, + tool_choice: nil + ) + unless capabilities&.dig(:sampling) + raise "Client does not support sampling." + end + + if tools && !capabilities.dig(:sampling, :tools) + raise "Client does not support sampling with tools." + end + + if tool_choice && !capabilities.dig(:sampling, :tools) + raise "Client does not support sampling with tool_choice." + end + + { + messages: messages, + maxTokens: max_tokens, + systemPrompt: system_prompt, + modelPreferences: model_preferences, + includeContext: include_context, + temperature: temperature, + stopSequences: stop_sequences, + metadata: metadata, + tools: tools, + toolChoice: tool_choice, + }.compact + end + private def validate! @@ -371,10 +449,11 @@ module MCP session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) else @client = params[:clientInfo] + @client_capabilities = params[:capabilities] end + protocol_version = params[:protocolVersion] end - protocol_version = params[:protocolVersion] if params negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) protocol_version else diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index 9d54cec..3d6a4ff 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -53,6 +53,41 @@ module MCP MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" }) false end + + def send_request(method, params = nil) + request_id = generate_request_id + request = { jsonrpc: "2.0", id: request_id, method: method } + request[:params] = params if params + + begin + send_response(request) + rescue => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" }) + raise + end + + while @open && (line = $stdin.gets) + begin + parsed = JSON.parse(line.strip, symbolize_names: true) + rescue JSON::ParserError => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" }) + raise + end + + if parsed[:id] == request_id && !parsed.key?(:method) + if parsed[:error] + raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}" + end + + return parsed[:result] + else + response = @session ? @session.handle(parsed) : @server.handle(parsed) + send_response(response) if response + end + end + + raise "Transport closed while waiting for response to #{method} request." + end end end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 14b6906..31ddc89 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "json" -require "securerandom" require_relative "../../transport" module MCP @@ -16,6 +15,7 @@ module MCP @stateless = stateless @session_idle_timeout = session_idle_timeout + @pending_responses = {} if @session_idle_timeout if @stateless @@ -132,6 +132,77 @@ module MCP result end + # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and + # blocks until the client responds. + # + # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`, + # sends the request via SSE stream, then blocks on `queue.pop`. + # When the client POSTs a response, `handle_response` matches it by `request_id` + # and pushes the result onto the queue, unblocking this thread. + def send_request(method, params = nil, session_id: nil) + if @stateless + raise "Stateless mode does not support server-to-client requests." + end + + unless session_id + raise "session_id is required for server-to-client requests." + end + + request_id = generate_request_id + queue = Queue.new + + request = { jsonrpc: "2.0", id: request_id, method: method } + request[:params] = params if params + + sent = false + + @mutex.synchronize do + unless (session = @sessions[session_id]) + raise "Session not found: #{session_id}." + end + + @pending_responses[request_id] = { queue: queue, session_id: session_id } + + if (stream = session[:stream]) + begin + send_to_stream(stream, request) + sent = true + rescue *STREAM_WRITE_ERRORS + cleanup_session_unsafe(session_id) + end + end + end + + # TODO: Replace with event store + replay when resumability is implemented. + # Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay, + # event store management) independent of sampling. + # See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery + # + # The TypeScript and Python SDKs buffer messages and replay on reconnect. + # Until then, raise to prevent queue.pop from blocking indefinitely. + unless sent + raise "No active SSE stream for #{method} request." + end + + response = queue.pop + + if response.is_a?(Hash) && response.key?(:error) + raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}" + end + + if response == :session_closed + raise "SSE session closed while waiting for #{method} response." + end + + response + ensure + if request_id + @mutex.synchronize do + @pending_responses.delete(request_id) + end + end + end + private def start_reaper_thread @@ -187,8 +258,12 @@ module MCP else return missing_session_id_response if !@stateless && !session_id - if notification?(body) || response?(body) + if notification?(body) handle_accepted + elsif response?(body) + return session_not_found_response if !@stateless && !session_exists?(session_id) + + handle_response(body, session_id: session_id) else handle_regular_request(body_string, session_id) end @@ -245,7 +320,16 @@ module MCP # Callers must close the stream outside the mutex to avoid holding the lock during # potentially blocking I/O. def cleanup_session_unsafe(session_id) - @sessions.delete(session_id) + session = @sessions.delete(session_id) + + # Unblock threads waiting on pending responses for this session. + @pending_responses.each_value do |pending_response| + if pending_response[:session_id] == session_id + pending_response[:queue].push(:session_closed) + end + end + + session end def cleanup_and_collect_stream(session_id, streams_to_close) @@ -305,6 +389,24 @@ module MCP !!body[:id] && !body[:method] end + # Verifies that the response came from the expected session to prevent + # cross-session response injection if request IDs are ever leaked. + def handle_response(body, session_id:) + request_id = body[:id] + @mutex.synchronize do + if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id + if body.key?(:error) + error = body[:error] + pending_response[:queue].push(error: { code: error[:code], message: error[:message] }) + else + pending_response[:queue].push(body[:result]) + end + end + end + + handle_accepted + end + def handle_initialization(body_string, body) session_id = nil server_session = nil diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 7d1f2d9..b532555 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -29,6 +29,19 @@ module MCP @notification_target.notify_log_message(data: data, level: level, logger: logger) end + # 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. + def create_sampling_message(**kwargs) + if @notification_target.respond_to?(:create_sampling_message) + @notification_target.create_sampling_message(**kwargs) + elsif @context.respond_to?(:create_sampling_message) + @context.create_sampling_message(**kwargs) + else + raise NoMethodError, "undefined method 'create_sampling_message' for #{self}" + end + end + def method_missing(name, ...) if @context.respond_to?(name) @context.public_send(name, ...) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index a1cbfd0..93e823f 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -13,7 +13,7 @@ module MCP @transport = transport @session_id = session_id @client = nil - @client_capabilities = nil # TODO: Use for per-session capability validation. + @client_capabilities = nil @logging_message_notification = nil end @@ -36,6 +36,17 @@ module MCP @logging_message_notification = logging_message_notification end + # Returns per-session client capabilities, falling back to global. + def client_capabilities + @client_capabilities || @server.client_capabilities + end + + # Sends a `sampling/createMessage` request scoped to this session. + def create_sampling_message(**kwargs) + params = @server.build_sampling_params(client_capabilities, **kwargs) + send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params) + end + # Sends a progress notification to this session only. def notify_progress(progress_token:, progress:, total: nil, message: nil) params = { @@ -65,6 +76,9 @@ module MCP private + # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without + # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one. + # # TODO: When Ruby 2.7 support is dropped, replace with a direct call: # `@transport.send_notification(method, params, session_id: @session_id)` and # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`. @@ -75,5 +89,19 @@ module MCP @transport.send_notification(method, params) end end + + # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without + # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one. + # + # TODO: When Ruby 2.7 support is dropped, replace with a direct call: + # `@transport.send_request(method, params, session_id: @session_id)` and + # add `**` to `Transport#send_request` and `StdioTransport#send_request`. + def send_to_transport_request(method, params) + if @session_id + @transport.send_request(method, params, session_id: @session_id) + else + @transport.send_request(method, params) + end + end end end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index 99b8892..4e3f85f 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "securerandom" + module MCP class Transport # Initialize the transport with the server instance @@ -41,5 +43,16 @@ module MCP def send_notification(method, params = nil) raise NotImplementedError, "Subclasses must implement send_notification" end + + # Send a JSON-RPC request to the client and wait for a response. + def send_request(method, params = nil) + raise NotImplementedError, "Subclasses must implement send_request" + end + + private + + def generate_request_id + SecureRandom.uuid + end end end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 59d3abe..e9b1011 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -151,6 +151,247 @@ module MCP $stdout = original_stdout end end + + test "send_request sends request to stdout and waits for response" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Send response from client in a thread. + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + response = { + jsonrpc: "2.0", + id: request[:id], + result: { content: "test response" }, + } + writer.puts(response.to_json) + writer.flush + end + + result = @transport.send_request("test/method", { param: "value" }) + + assert_equal({ content: "test response" }, result) + + # Verify request was sent. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + assert_equal("2.0", request[:jsonrpc]) + assert_equal("test/method", request[:method]) + assert_equal({ param: "value" }, request[:params]) + assert(request[:id]) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request raises on error response from client" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + error_response = { + jsonrpc: "2.0", + id: request[:id], + error: { code: -1, message: "User rejected sampling request" }, + } + writer.puts(error_response.to_json) + writer.flush + end + + error = assert_raises(StandardError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected sampling request", error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request does not double-report intentional raises via exception_reporter" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + reported_errors = [] + original_reporter = MCP.configuration.exception_reporter + + begin + MCP.configuration.exception_reporter = ->(e, ctx) { reported_errors << [e, ctx] } + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + error_response = { + jsonrpc: "2.0", + id: request[:id], + error: { code: -1, message: "rejected" }, + } + writer.puts(error_response.to_json) + writer.flush + end + + assert_raises(StandardError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_empty(reported_errors) + ensure + MCP.configuration.exception_reporter = original_reporter + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request processes interleaved requests via session" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Initialize a session so @session is set. + session = MCP::ServerSession.new(server: @server, transport: @transport) + @transport.instance_variable_set(:@session, session) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + + # Send an interleaved ping request before the response. + ping = { jsonrpc: "2.0", method: "ping", id: "ping-1" } + writer.puts(ping.to_json) + writer.flush + + sleep(0.05) # Wait for the ping to be processed. + + # Then send the actual response. + response = { + jsonrpc: "2.0", + id: request[:id], + result: { content: "done" }, + } + writer.puts(response.to_json) + writer.flush + end + + result = @transport.send_request("test/method", { param: "value" }) + + assert_equal({ content: "done" }, result) + + # Verify the interleaved ping was handled (response sent to output). + lines = output.string.lines + ping_response = lines.find { |l| l.include?("ping-1") } + assert(ping_response, "Interleaved ping request should have been handled") + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request raises when transport is closed while waiting" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Close transport while waiting for response. + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + @transport.instance_variable_set(:@open, false) + writer.close + end + + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_equal("Transport closed while waiting for response to sampling/createMessage request.", error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue IOError + nil + end + begin + reader.close + rescue IOError + nil + end + end + end 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 d4faa8d..507ac45 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1395,6 +1395,294 @@ module MCP assert_equal "Internal server error", body["error"] end + test "send_request raises error in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + error = assert_raises(RuntimeError) do + stateless_transport.send_request("sampling/createMessage", { "messages" => [] }) + end + + assert_equal("Stateless mode does not support server-to-client requests.", error.message) + end + + test "send_request raises error when session_id is not provided" do + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }) + end + + assert_equal("session_id is required for server-to-client requests.", error.message) + end + + test "send_request raises error when session is not found" do + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: "nonexistent") + end + + assert_equal("Session not found: nonexistent.", error.message) + end + + test "send_request raises error when no active SSE streams" do + # Create session but do NOT connect SSE. + 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"] + + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: session_id) + end + + assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + end + + test "send_request sends via SSE and waits for response" do + # Create session. + 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 SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + # Send request in background. + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [{ role: "user", content: { type: "text", text: "Hello" } }], maxTokens: 100 }, + session_id: session_id, + ) + result_queue.push(result) + end + + sleep(0.1) # Wait for the request to be sent. + + # Verify request was sent to stream. + io.rewind + output = io.read + assert_includes output, "sampling/createMessage" + + # Parse the sent request to get its ID. + data_lines = output.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Simulate client response. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: request_id, + result: { role: "assistant", content: { type: "text", text: "Hi there" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + # Get result. + result = result_queue.pop + assert_equal "assistant", result[:role] + assert_equal "Hi there", result[:content][:text] + end + + test "send_request ignores response from wrong session" do + # Create two sessions. + init_a = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-a" }.to_json, + ) + resp_a = @transport.handle_request(init_a) + session_a = resp_a[1]["Mcp-Session-Id"] + + init_b = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-b" }.to_json, + ) + resp_b = @transport.handle_request(init_b) + session_b = resp_b[1]["Mcp-Session-Id"] + + # Connect SSE for session A. + io_a = StringIO.new + get_a = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_a }) + response_a = @transport.handle_request(get_a) + response_a[2].call(io_a) if response_a[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + # Send sampling request targeting session A. + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [{ role: "user", content: { type: "text", text: "Hello" } }], maxTokens: 100 }, + session_id: session_a, + ) + result_queue.push(result) + end + + sleep(0.1) # Wait for the request to be sent. + + # Get the request ID from session A's stream. + io_a.rewind + data_lines = io_a.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Session B tries to respond (cross-session injection attempt). + cross_session_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_b }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "injected" } } }.to_json, + ) + @transport.handle_request(cross_session_response) + + # The request should still be pending (not resolved by wrong session). + assert_empty(result_queue, "Response from wrong session should be ignored") + + # Now send the correct response from session A. + correct_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_a }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "correct" } } }.to_json, + ) + @transport.handle_request(correct_response) + + result = result_queue.pop + assert_equal "correct", result[:content][:text] + end + + test "send_request raises on error response from client" do + # Create session. + 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 SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + error_queue = Queue.new + Thread.new do + @transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + rescue => e + error_queue.push(e) + end + + sleep(0.1) # Wait for the request to be sent. + + # Get request ID from stream. + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Send error response. + error_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: request_id, + error: { code: -1, message: "User rejected" }, + }.to_json, + ) + @transport.handle_request(error_response) + + error = error_queue.pop + assert_kind_of StandardError, error + assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected", error.message) + end + + test "send_request unblocks when session is cleaned up" do + # Create session. + 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 SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + error_queue = Queue.new + Thread.new do + @transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + rescue => e + error_queue.push(e) + end + + sleep(0.1) # Wait for the request to be sent. + + # Delete the session to trigger cleanup (simulates client disconnect). + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + @transport.handle_request(delete_request) + + error = error_queue.pop + assert_kind_of RuntimeError, error + assert_equal("SSE session closed while waiting for sampling/createMessage response.", error.message) + end + test "POST notifications/initialized returns 202 with no body" do # Create a session first (optional for notification, but keep consistent with flow) init_request = create_rack_request( @@ -1821,6 +2109,45 @@ module MCP assert_equal([], response[2]) end + test "POST response without session ID returns 400" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", result: "success", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "POST response with invalid session ID returns 404" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + @transport.handle_request(init_request) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "invalid-session-id", + }, + { jsonrpc: "2.0", result: "success", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"] + end + test "handle_regular_request returns 404 for unknown session_id" do request = create_rack_request( "POST", diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index dd96796..605e385 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -41,6 +41,46 @@ module MCP assert_raises(NoMethodError) { server_context.nonexistent_method } end + test "ServerContext#create_sampling_message delegates to notification_target over context" do + notification_target = mock + notification_target.expects(:create_sampling_message).with( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ).returns({ role: "assistant", content: { type: "text", text: "Hi" } }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal "Hi", result[:content][:text] + end + + test "ServerContext#create_sampling_message falls back to context when notification_target does not respond" do + notification_target = mock + context = mock + context.expects(:create_sampling_message).with( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ).returns({ role: "assistant", content: { type: "text", text: "Fallback" } }) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal "Fallback", result[:content][:text] + end + test "ServerContext delegates to custom object context" do context = Object.new def context.custom_method diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb new file mode 100644 index 0000000..57c488d --- /dev/null +++ b/test/mcp/server_sampling_test.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerSamplingTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + class MockTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil) + @requests << { method: method, params: params } + { + role: "assistant", + content: { type: "text", text: "Response from LLM" }, + model: "test-model", + stopReason: "endTurn", + } + end + + def send_response(response); end + def send_notification(method, params = nil); end + def open; end + def close; end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: configuration, + ) + + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + + # Simulate client initialization with sampling capability. + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + end + + test "create_sampling_message sends request with required params" do + result = @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal 1, @mock_transport.requests.size + request = @mock_transport.requests.first + assert_equal Methods::SAMPLING_CREATE_MESSAGE, request[:method] + assert_equal 100, request[:params][:maxTokens] + assert_equal [{ role: "user", content: { type: "text", text: "Hello" } }], request[:params][:messages] + + assert_equal "assistant", result[:role] + assert_equal "Response from LLM", result[:content][:text] + end + + test "create_sampling_message sends all optional params" do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + system_prompt: "You are helpful", + model_preferences: { intelligencePriority: 0.8 }, + include_context: "none", + temperature: 0.7, + stop_sequences: ["STOP"], + metadata: { key: "value" }, + ) + + request = @mock_transport.requests.first + params = request[:params] + + assert_equal "You are helpful", params[:systemPrompt] + assert_equal({ intelligencePriority: 0.8 }, params[:modelPreferences]) + assert_equal "none", params[:includeContext] + assert_equal 0.7, params[:temperature] + assert_equal ["STOP"], params[:stopSequences] + assert_equal({ key: "value" }, params[:metadata]) + end + + test "create_sampling_message raises error when transport is not set" do + server_without_transport = Server.new(name: "test", version: "1.0") + + # Initialize with sampling capability but no transport. + server_without_transport.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + error = assert_raises(RuntimeError) do + server_without_transport.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + + assert_equal("Cannot send sampling request without a transport.", error.message) + end + + test "create_sampling_message raises error when client does not support sampling" do + # Re-initialize without sampling capability. + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + error = assert_raises(RuntimeError) do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + + assert_equal("Client does not support sampling.", error.message) + end + + test "create_sampling_message raises error when tools used but client lacks sampling.tools" do + error = assert_raises(RuntimeError) do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tools: [{ name: "test_tool", inputSchema: { type: "object" } }], + ) + end + + assert_equal("Client does not support sampling with tools.", error.message) + end + + test "create_sampling_message raises error when tool_choice used alone but client lacks sampling.tools" do + error = assert_raises(RuntimeError) do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tool_choice: { mode: "auto" }, + ) + end + + assert_equal("Client does not support sampling with tool_choice.", error.message) + end + + test "create_sampling_message allows tools when client has sampling.tools capability" do + # Re-initialize with sampling.tools capability. + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 3, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: { tools: {} } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + result = @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tools: [{ name: "test_tool", inputSchema: { type: "object" } }], + tool_choice: { mode: "auto" }, + ) + + request = @mock_transport.requests.first + params = request[:params] + + assert_equal [{ name: "test_tool", inputSchema: { type: "object" } }], params[:tools] + assert_equal({ mode: "auto" }, params[:toolChoice]) + assert_equal "Response from LLM", result[:content][:text] + end + + test "init with sampling capability allows create_sampling_message" do + server = Server.new(name: "test", version: "1.0") + mock_transport = MockTransport.new(server) + server.transport = mock_transport + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: { tools: {} } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + result = server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tools: [{ name: "t", inputSchema: { type: "object" } }], + ) + + assert_equal "assistant", result[:role] + end + + test "init without capabilities rejects create_sampling_message" do + server = Server.new(name: "test", version: "1.0") + mock_transport = MockTransport.new(server) + server.transport = mock_transport + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + error = assert_raises(RuntimeError) do + server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + + assert_equal("Client does not support sampling.", error.message) + end + + test "create_sampling_message uses per-session capabilities via ServerSession" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + @server.transport = transport + + # Session with sampling capability passes validation (fails at send_request due to no stream). + session_with_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s1") + session_with_sampling.store_client_info(client: { name: "capable" }, capabilities: { sampling: {} }) + 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, + ) + end + assert_equal("No active SSE stream for sampling/createMessage request.", error_with_sampling.message) + + # Session without sampling capability should be rejected. + session_without_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s2") + 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, + ) + end + assert_equal("Client does not support sampling.", error.message) + end + + test "ServerSession#client_capabilities falls back to server global capabilities" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + @server.transport = transport + + # Session without capabilities stored falls back to @server.client_capabilities. + session = ServerSession.new(server: @server, transport: transport, session_id: "s3") + transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session } + + # Server was initialized with sampling capability in setup, 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, + ) + end + assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + end + + test "session init does not overwrite server global client_capabilities" do + server = Server.new(name: "test", version: "1.0") + mock_transport = MockTransport.new(server) + server.transport = mock_transport + + # Non-session init sets global capabilities. + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "first-client", version: "1.0" }, + }, + }) + + assert_equal({ sampling: {} }, server.client_capabilities) + + # Session-scoped init must NOT overwrite global capabilities. + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + server.transport = transport + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "second-client", version: "1.0" }, + }, + }, + session: session, + ) + + # Global must still have sampling. + assert_equal({ sampling: {} }, server.client_capabilities) + # Session must have its own (empty) capabilities. + assert_equal({}, session.client_capabilities) + end + + test "Server#create_sampling_message does not see session-scoped capabilities from HTTP init" do + server = Server.new(name: "test", version: "1.0") + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + server.transport = transport + + # HTTP init stores capabilities on the session, not on the server. + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "http-client", version: "1.0" }, + }, + }, + session: session, + ) + + # Server-level API should not see session-scoped capabilities. + error = assert_raises(RuntimeError) do + server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + assert_equal("Client does not support sampling.", error.message) + + # Session-scoped API should work (fails at transport level, not capability). + transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session } + error = assert_raises(RuntimeError) do + session.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + end + + test "create_sampling_message omits nil optional params" do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + system_prompt: nil, + temperature: nil, + ) + + request = @mock_transport.requests.first + params = request[:params] + + refute params.key?(:systemPrompt) + refute params.key?(:temperature) + assert params.key?(:messages) + assert params.key?(:maxTokens) + end + end +end From 8278ca658669c6b57b938faca13a70d2304234d9 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 1 Apr 2026 10:10:59 +0900 Subject: [PATCH 023/247] Support POST response SSE streams for server-to-client messages ## Motivation and Context The MCP Streamable HTTP specification defines that servers can return POST responses as SSE streams and send server-to-client JSON-RPC requests and notifications through them: > If the input is a JSON-RPC request, the server MUST either return > `Content-Type: text/event-stream`, to initiate an SSE stream, or > `Content-Type: application/json`, to return one JSON object. If the server initiates an SSE stream: > The server MAY send JSON-RPC requests and notifications before > sending the JSON-RPC response. These messages SHOULD relate to the > originating client request. See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server Previously, when a GET SSE stream was connected, the Ruby SDK sent POST response bodies through the GET stream and returned a 202 HTTP status for the POST itself. When no GET SSE stream was connected, the SDK correctly returned `application/json` responses. The 202 behavior with GET SSE was non-compliant with the specification, which requires POST requests to return `text/event-stream` or `application/json`. Additionally, since GET SSE is optional per the specification, clients that did not establish a GET SSE connection could not receive server-to-client messages (e.g., `sampling/createMessage`, log notifications) during request processing. With this change, `handle_regular_request` always returns the POST response as an SSE stream for stateful sessions. Each POST response stream is stored in `session[:post_request_streams]` keyed by `related_request_id` (the JSON-RPC request ID), enabling correct routing when multiple POST requests are processed concurrently on the same session. Server-to-client messages with a `related_request_id` are routed to the originating POST response stream only; when `related_request_id` is nil, the GET SSE stream is used. The TypeScript and Python SDKs already support this pattern. ### Internal Changes `JsonRpcHandler.handle` and `JsonRpcHandler.handle_json` now pass both `method_name` and `request_id` to the method finder block. This allows `Server#handle_request` to receive `related_request_id` directly from the protocol layer. Without this, `related_request_id` would need to be relayed as a keyword argument through `Server#handle_json`, `ServerSession#handle_json`, and `dispatch_handle_json`, unnecessarily exposing it on public method signatures. This follows the same design as the TypeScript and Python SDKs, where the protocol layer extracts the request ID and propagates it to the handler context. ## How Has This Been Tested? Added tests for POST response stream: - `send_request` via POST response stream (sampling with and without GET SSE) - `send_notification` via POST response stream (logging without GET SSE) - `progress` notification via POST response stream (without GET SSE) - POST request returns SSE response even with GET SSE connected - Session-scoped notifications (log, progress) are sent to POST response stream, not GET SSE stream - `active_stream` does not fall back to GET SSE when `related_request_id` is given but request stream is missing Updated existing tests to handle SSE response format where applicable. ## Breaking Changes (spec compliance fix) POST responses for JSON-RPC requests in stateful sessions now return `Content-Type: text/event-stream` instead of being sent through the GET SSE stream with a 202 HTTP status. Clients that relied on receiving responses via the GET SSE stream will need to read the POST response body instead. This is a spec compliance fix: the MCP specification requires POST requests to return `text/event-stream` or `application/json`, not 202 with the response on a separate GET stream. --- lib/json_rpc_handler.rb | 2 +- lib/mcp/progress.rb | 4 +- lib/mcp/server.rb | 25 +- .../transports/streamable_http_transport.rb | 157 +++-- lib/mcp/server_context.rb | 9 +- lib/mcp/server_session.rb | 20 +- test/json_rpc_handler_test.rb | 16 +- .../streamable_http_transport_test.rb | 575 +++++++++++++++--- test/mcp/server_context_test.rb | 2 + test/mcp/server_sampling_test.rb | 17 +- 10 files changed, 672 insertions(+), 155 deletions(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index d4be6a7..b788e12 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -92,7 +92,7 @@ module JsonRpcHandler end begin - method = method_finder.call(method_name) + method = method_finder.call(method_name, id) if method.nil? return error_response(id: id, id_validation_pattern: id_validation_pattern, error: { diff --git a/lib/mcp/progress.rb b/lib/mcp/progress.rb index 8843a0d..6762d3c 100644 --- a/lib/mcp/progress.rb +++ b/lib/mcp/progress.rb @@ -2,9 +2,10 @@ module MCP class Progress - def initialize(notification_target:, progress_token:) + def initialize(notification_target:, progress_token:, related_request_id: nil) @notification_target = notification_target @progress_token = progress_token + @related_request_id = related_request_id end def report(progress, total: nil, message: nil) @@ -16,6 +17,7 @@ module MCP progress: progress, total: total, message: message, + related_request_id: @related_request_id, ) end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 484b29b..d085b8b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -127,8 +127,8 @@ module MCP # When `nil`, progress and logging notifications from tool handlers are silently skipped. # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. def handle(request, session: nil) - JsonRpcHandler.handle(request) do |method| - handle_request(request, method, session: session) + JsonRpcHandler.handle(request) do |method, request_id| + handle_request(request, method, session: session, related_request_id: request_id) end end @@ -140,8 +140,8 @@ module MCP # When `nil`, progress and logging notifications from tool handlers are silently skipped. # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. def handle_json(request, session: nil) - JsonRpcHandler.handle_json(request) do |method| - handle_request(request, method, session: session) + JsonRpcHandler.handle_json(request) do |method, request_id| + handle_request(request, method, session: session, related_request_id: request_id) end end @@ -220,7 +220,8 @@ module MCP stop_sequences: nil, metadata: nil, tools: nil, - tool_choice: nil + tool_choice: nil, + related_request_id: nil ) unless @transport raise "Cannot send sampling request without a transport." @@ -371,7 +372,7 @@ module MCP end end - def handle_request(request, method, session: nil) + def handle_request(request, method, session: nil, related_request_id: nil) handler = @handlers[method] unless handler instrument_call("unsupported_method") do @@ -399,7 +400,7 @@ module MCP when Methods::RESOURCES_TEMPLATES_LIST { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } when Methods::TOOLS_CALL - call_tool(params, session: session) + call_tool(params, session: session, related_request_id: related_request_id) when Methods::COMPLETION_COMPLETE complete(params) when Methods::LOGGING_SET_LEVEL @@ -499,7 +500,7 @@ module MCP @tools.values.map(&:to_h) end - def call_tool(request, session: nil) + def call_tool(request, session: nil, related_request_id: nil) tool_name = request[:name] tool = tools[tool_name] @@ -531,7 +532,7 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session) + call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id) rescue RequestHandlerError raise rescue => e @@ -611,12 +612,12 @@ module MCP parameters.any? { |type, name| type == :keyrest || name == :server_context } end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil) args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) - progress = Progress.new(notification_target: session, progress_token: progress_token) - server_context = ServerContext.new(context, progress: progress, notification_target: session) + progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id) tool.call(**args, server_context: server_context).to_h else tool.call(**args).to_h diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 31ddc89..688e38c 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -7,6 +7,12 @@ module MCP class Server module Transports class StreamableHTTPTransport < Transport + SSE_HEADERS = { + "Content-Type" => "text/event-stream", + "Cache-Control" => "no-cache", + "Connection" => "keep-alive", + }.freeze + def initialize(server, stateless: false, session_idle_timeout: nil) super(server) # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @@ -56,10 +62,11 @@ module MCP removed_sessions.each do |session| close_stream_safely(session[:stream]) + close_post_request_streams(session) end end - def send_notification(method, params = nil, session_id: nil) + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) # Stateless mode doesn't support notifications raise "Stateless mode does not support notifications" if @stateless @@ -74,8 +81,10 @@ module MCP result = @mutex.synchronize do if session_id # Send to specific session - session = @sessions[session_id] - next false unless session && session[:stream] + if (session = @sessions[session_id]) + stream = active_stream(session, related_request_id: related_request_id) + end + next false unless stream if session_expired?(session) cleanup_and_collect_stream(session_id, streams_to_close) @@ -83,14 +92,19 @@ module MCP end begin - send_to_stream(session[:stream], notification) + send_to_stream(stream, notification) true rescue *STREAM_WRITE_ERRORS => e MCP.configuration.exception_reporter.call( e, { session_id: session_id, error: "Failed to send notification" }, ) - cleanup_and_collect_stream(session_id, streams_to_close) + if related_request_id && session[:post_request_streams]&.key?(related_request_id) + session[:post_request_streams].delete(related_request_id) + streams_to_close << stream + else + cleanup_and_collect_stream(session_id, streams_to_close) + end false end else @@ -99,7 +113,7 @@ module MCP failed_sessions = [] @sessions.each do |sid, session| - next unless session[:stream] + next unless (stream = session[:stream]) if session_expired?(session) failed_sessions << sid @@ -107,7 +121,7 @@ module MCP end begin - send_to_stream(session[:stream], notification) + send_to_stream(stream, notification) sent_count += 1 rescue *STREAM_WRITE_ERRORS => e MCP.configuration.exception_reporter.call( @@ -139,7 +153,7 @@ module MCP # sends the request via SSE stream, then blocks on `queue.pop`. # When the client POSTs a response, `handle_response` matches it by `request_id` # and pushes the result onto the queue, unblocking this thread. - def send_request(method, params = nil, session_id: nil) + def send_request(method, params = nil, session_id: nil, related_request_id: nil) if @stateless raise "Stateless mode does not support server-to-client requests." end @@ -163,12 +177,17 @@ module MCP @pending_responses[request_id] = { queue: queue, session_id: session_id } - if (stream = session[:stream]) + if (stream = active_stream(session, related_request_id: related_request_id)) begin send_to_stream(stream, request) sent = true rescue *STREAM_WRITE_ERRORS - cleanup_session_unsafe(session_id) + if related_request_id && session[:post_request_streams]&.key?(related_request_id) + session[:post_request_streams].delete(related_request_id) + close_stream_safely(stream) + else + cleanup_session_unsafe(session_id) + end end end end @@ -181,7 +200,7 @@ module MCP # The TypeScript and Python SDKs buffer messages and replay on reconnect. # Until then, raise to prevent queue.pop from blocking indefinitely. unless sent - raise "No active SSE stream for #{method} request." + raise "No active stream for #{method} request." end response = queue.pop @@ -229,6 +248,7 @@ module MCP removed_sessions.each do |session| close_stream_safely(session[:stream]) + close_post_request_streams(session) end end @@ -265,7 +285,7 @@ module MCP handle_response(body, session_id: session_id) else - handle_regular_request(body_string, session_id) + handle_regular_request(body_string, session_id, related_request_id: body[:id]) end end rescue StandardError => e @@ -313,7 +333,10 @@ module MCP cleanup_session_unsafe(session_id) end - close_stream_safely(session[:stream]) if session + if session + close_stream_safely(session[:stream]) + close_post_request_streams(session) + end end # Removes a session from `@sessions` and returns it. Does not close the stream. @@ -336,6 +359,7 @@ module MCP return unless (removed = cleanup_session_unsafe(session_id)) streams_to_close << removed[:stream] + removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream } end def close_stream_safely(stream) @@ -344,6 +368,14 @@ module MCP # Ignore close-related errors from already closed/broken streams. end + def close_post_request_streams(session) + return unless (post_request_streams = session[:post_request_streams]) + + post_request_streams.each_value do |stream| + close_stream_safely(stream) + end + end + def extract_session_id(request) request.env["HTTP_MCP_SESSION_ID"] end @@ -443,9 +475,8 @@ module MCP [202, {}, []] end - def handle_regular_request(body_string, session_id) + def handle_regular_request(body_string, session_id, related_request_id: nil) server_session = nil - stream = nil unless @stateless if session_id @@ -455,22 +486,73 @@ module MCP @mutex.synchronize do session = @sessions[session_id] server_session = session[:server_session] if session - stream = session[:stream] if session end end end - response = if server_session + if session_id && !@stateless + handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) + else + response = dispatch_handle_json(body_string, server_session) + [200, { "Content-Type" => "application/json" }, [response]] + end + end + + # Returns the POST response as an SSE stream so the server can send + # JSON-RPC requests and notifications during request processing. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil) + body = proc do |stream| + @mutex.synchronize do + session = @sessions[session_id] + if session && related_request_id + session[:post_request_streams] ||= {} + session[:post_request_streams][related_request_id] = stream + end + end + + begin + response = dispatch_handle_json(body_string, server_session) + + send_to_stream(stream, response) if response + ensure + if related_request_id + @mutex.synchronize do + session = @sessions[session_id] + session[:post_request_streams]&.delete(related_request_id) if session + end + end + + begin + stream.close + rescue StandardError + # Ignore close-related errors from already closed/broken streams. + end + end + end + + [200, SSE_HEADERS, body] + end + + # Returns the SSE stream available for server-to-client messages. + # When `related_request_id` is given, returns only the POST response + # stream for that request (no fallback to GET SSE). This prevents + # request-scoped messages from leaking to the wrong stream. + # When `related_request_id` is nil, returns the GET SSE stream. + def active_stream(session, related_request_id: nil) + if related_request_id + session.dig(:post_request_streams, related_request_id) + else + session[:stream] + end + end + + def dispatch_handle_json(body_string, server_session) + if server_session server_session.handle_json(body_string) else @server.handle_json(body_string) end - - if stream - send_response_to_stream(stream, response, session_id) - else - [200, { "Content-Type" => "application/json" }, [response]] - end end def validate_and_touch_session(session_id) @@ -489,7 +571,13 @@ module MCP nil end - close_stream_safely(removed[:stream]) if removed + if removed + close_stream_safely(removed[:stream]) + + removed[:post_request_streams]&.each_value do |stream| + close_stream_safely(stream) + end + end response end @@ -498,19 +586,6 @@ module MCP @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) } end - def send_response_to_stream(stream, response, session_id) - message = JSON.parse(response) - send_to_stream(stream, message) - handle_accepted - rescue *STREAM_WRITE_ERRORS => e - MCP.configuration.exception_reporter.call( - e, - { session_id: session_id, error: "Stream closed during response" }, - ) - cleanup_session(session_id) - [200, { "Content-Type" => "application/json" }, [response]] - end - def session_exists?(session_id) @mutex.synchronize { @sessions.key?(session_id) } end @@ -538,13 +613,7 @@ module MCP def setup_sse_stream(session_id) body = create_sse_body(session_id) - headers = { - "Content-Type" => "text/event-stream", - "Cache-Control" => "no-cache", - "Connection" => "keep-alive", - } - - [200, headers, body] + [200, SSE_HEADERS, body] end def create_sse_body(session_id) diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index b532555..aadd750 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -2,10 +2,11 @@ module MCP class ServerContext - def initialize(context, progress:, notification_target:) + def initialize(context, progress:, notification_target:, related_request_id: nil) @context = context @progress = progress @notification_target = notification_target + @related_request_id = related_request_id end # Reports progress for the current tool operation. @@ -26,7 +27,7 @@ module MCP def notify_log_message(data:, level:, logger: nil) return unless @notification_target - @notification_target.notify_log_message(data: data, level: level, logger: logger) + @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end # Delegates to the session so the request is scoped to the originating client. @@ -34,9 +35,9 @@ module MCP # does not support sampling. def create_sampling_message(**kwargs) if @notification_target.respond_to?(:create_sampling_message) - @notification_target.create_sampling_message(**kwargs) + @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id) elsif @context.respond_to?(:create_sampling_message) - @context.create_sampling_message(**kwargs) + @context.create_sampling_message(**kwargs, related_request_id: @related_request_id) else raise NoMethodError, "undefined method 'create_sampling_message' for #{self}" end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 93e823f..2fe8f77 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -42,13 +42,13 @@ module MCP end # Sends a `sampling/createMessage` request scoped to this session. - def create_sampling_message(**kwargs) + def create_sampling_message(related_request_id: nil, **kwargs) params = @server.build_sampling_params(client_capabilities, **kwargs) - send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params) + send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id) end # Sends a progress notification to this session only. - def notify_progress(progress_token:, progress:, total: nil, message: nil) + def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil) params = { "progressToken" => progress_token, "progress" => progress, @@ -56,20 +56,20 @@ module MCP "message" => message, }.compact - send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params) + send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id) rescue => e @server.report_exception(e, notification: "progress") end # Sends a log message notification to this session only. - def notify_log_message(data:, level:, logger: nil) + def notify_log_message(data:, level:, logger: nil, related_request_id: nil) effective_logging = @logging_message_notification || @server.logging_message_notification return unless effective_logging&.should_notify?(level) params = { "data" => data, "level" => level } params["logger"] = logger if logger - send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params) + send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id) rescue => e @server.report_exception(e, { notification: "log_message" }) end @@ -82,9 +82,9 @@ module MCP # TODO: When Ruby 2.7 support is dropped, replace with a direct call: # `@transport.send_notification(method, params, session_id: @session_id)` and # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`. - def send_to_transport(method, params) + def send_to_transport(method, params, related_request_id: nil) if @session_id - @transport.send_notification(method, params, session_id: @session_id) + @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id) else @transport.send_notification(method, params) end @@ -96,9 +96,9 @@ module MCP # TODO: When Ruby 2.7 support is dropped, replace with a direct call: # `@transport.send_request(method, params, session_id: @session_id)` and # add `**` to `Transport#send_request` and `StdioTransport#send_request`. - def send_to_transport_request(method, params) + def send_to_transport_request(method, params, related_request_id: nil) if @session_id - @transport.send_request(method, params, session_id: @session_id) + @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id) else @transport.send_request(method, params) end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 67f47db..169b1fd 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -621,7 +621,7 @@ describe JsonRpcHandler do @response = JsonRpcHandler.handle( { jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } }, id_validation_pattern: custom_pattern, - ) { |method_name| @registry[method_name] } + ) { |method_name, _request_id| @registry[method_name] } assert_rpc_success expected_result: 3 assert_equal "user@example.com", @response[:id] @@ -633,7 +633,7 @@ describe JsonRpcHandler do @response = JsonRpcHandler.handle( { jsonrpc: "2.0", id: "id", method: "add", params: { a: 1, b: 2 } }, id_validation_pattern: nil, - ) { |method_name| @registry[method_name] } + ) { |method_name, _request_id| @registry[method_name] } assert_rpc_success expected_result: 3 assert_equal "", @response[:id] @@ -733,11 +733,11 @@ describe JsonRpcHandler do end def handle(request) - @response = JsonRpcHandler.handle(request) { |method_name| @registry[method_name] } + @response = JsonRpcHandler.handle(request) { |method_name, _request_id| @registry[method_name] } end def handle_json(request_json) - @response_json = JsonRpcHandler.handle_json(request_json) { |method_name| @registry[method_name] } + @response_json = JsonRpcHandler.handle_json(request_json) { |method_name, _request_id| @registry[method_name] } @response = JSON.parse(@response_json, symbolize_names: true) if @response_json end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 507ac45..7435289 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -7,6 +7,31 @@ module MCP class Server module Transports class StreamableHTTPTransportTest < ActiveSupport::TestCase + # A stream that buffers writes and remains readable after close. + class TestStream + def initialize + @buffer = "".dup + @closed = false + end + + def write(data) + raise IOError, "closed stream" if @closed + + @buffer << data + end + + def flush + end + + def close + @closed = true + end + + def string + @buffer + end + end + setup do @server = Server.new( name: "test_server", @@ -45,9 +70,11 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal "text/event-stream", response[1]["Content-Type"] - body = JSON.parse(response[2][0]) + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] assert_equal({}, body["result"]) @@ -114,8 +141,7 @@ module MCP assert response[2].is_a?(Proc) # The body should be a Proc for streaming end - test "handles POST request when IOError raised" do - # Create and initialize a session + test "handles POST request as SSE even when GET SSE stream is closed" do init_request = create_rack_request( "POST", "/", @@ -125,7 +151,7 @@ module MCP init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] - # Connect with SSE + # Connect with SSE then close it io = StringIO.new get_request = create_rack_request( "GET", @@ -134,13 +160,10 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(io) if response[2].is_a?(Proc) - - # Give the stream time to set up sleep(0.1) - - # Close the stream io.close + # POST request should still return SSE response via POST response stream request = create_rack_request( "POST", "/", @@ -151,17 +174,12 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, ) - # This should handle IOError and return the original response response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) - - # Verify session was cleaned up - assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + assert_equal "text/event-stream", response[1]["Content-Type"] end - test "handles POST request when Errno::EPIPE raised" do - # Create and initialize a session + test "handles POST request as SSE even when GET SSE stream has EPIPE" do init_request = create_rack_request( "POST", "/", @@ -171,10 +189,8 @@ module MCP init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] - # Create a pipe to simulate EPIPE condition + # Connect GET SSE with a broken pipe reader, writer = IO.pipe - - # Connect with SSE using the writer end of the pipe get_request = create_rack_request( "GET", "/", @@ -182,13 +198,10 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(writer) if response[2].is_a?(Proc) - - # Give the stream time to set up sleep(0.1) - - # Close the reader end to break the pipe - this will cause EPIPE on write reader.close + # POST request should still return SSE response via POST response stream request = create_rack_request( "POST", "/", @@ -199,23 +212,18 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, ) - # This should handle Errno::EPIPE and return the original response response = @transport.handle_request(request) - assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) - - # Verify session was cleaned up - assert_not @transport.instance_variable_get(:@sessions).key?(session_id) - + assert_equal(200, response[0]) + assert_equal("text/event-stream", response[1]["Content-Type"]) + ensure begin writer.close - rescue + rescue StandardError nil end end - test "handles POST request when Errno::ECONNRESET raised" do - # Create and initialize a session. + test "handles POST request as SSE even when GET SSE stream has ECONNRESET" do init_request = create_rack_request( "POST", "/", @@ -225,12 +233,10 @@ module MCP init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] - # Use a mock stream that raises Errno::ECONNRESET on write. + # Connect GET SSE with a mock that raises ECONNRESET mock_stream = Object.new mock_stream.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } mock_stream.define_singleton_method(:close) {} - - # Connect with SSE using the mock stream. get_request = create_rack_request( "GET", "/", @@ -238,10 +244,9 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(mock_stream) if response[2].is_a?(Proc) - - # Give the stream time to set up. sleep(0.1) + # POST request should still return SSE response via POST response stream request = create_rack_request( "POST", "/", @@ -252,13 +257,9 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, ) - # This should handle Errno::ECONNRESET and return the original response. response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) - - # Verify session was cleaned up. - assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + assert_equal "text/event-stream", response[1]["Content-Type"] end test "handles GET request with missing session ID" do @@ -579,6 +580,54 @@ module MCP assert_equal({}, @transport.instance_variable_get(:@sessions)) end + test "cleanup_session_unsafe closes request_streams" do + 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"] + + # Simulate multiple request_streams being set on the session. + closed = [] + 2.times do |i| + mock_stream = Object.new + mock_stream.define_singleton_method(:close) { closed << i } + thread = Thread.new {} + thread.join + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] ||= {} + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams][thread] = mock_stream + end + + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + @transport.handle_request(delete_request) + + assert_equal [0, 1], closed.sort + assert_empty @transport.instance_variable_get(:@sessions) + end + + test "broadcast notification skips sessions without GET SSE stream" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + @transport.handle_request(init_request) + + # No GET SSE stream connected, only request_streams. + # Pass **{} to prevent Ruby 2.7 from converting the Hash to keyword arguments. + result = @transport.send_notification("test/notify", { message: "hello" }, **{}) + + assert_equal 0, result + end + test "sends notification to correct session with multiple active sessions" do # Create first session init_request1 = create_rack_request( @@ -653,8 +702,9 @@ module MCP result end - # Handle request from session 1 - @transport.handle_request(request_as_session1) + # Handle request from session 1 (execute SSE proc) + response1 = @transport.handle_request(request_as_session1) + response1[2].call(StringIO.new) if response1[2].is_a?(Proc) # Make a request as session 2 request_as_session2 = create_rack_request( @@ -667,18 +717,17 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "890" }.to_json, ) - # Handle request from session 2 - @transport.handle_request(request_as_session2) + # Handle request from session 2 (execute SSE proc) + response2_post = @transport.handle_request(request_as_session2) + response2_post[2].call(StringIO.new) if response2_post[2].is_a?(Proc) - # Check that each session received one notification + # Broadcast notifications are sent to GET SSE streams (no related_request_id) io1.rewind output1 = io1.read - # Session 1 should have received two notifications (one from each request since we broadcast) assert_equal 2, output1.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count io2.rewind output2 = io2.read - # Session 2 should have received two notifications (one from each request since we broadcast) assert_equal 2, output2.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count end @@ -888,6 +937,85 @@ module MCP assert_not @transport.instance_variable_get(:@sessions).key?(session_id) end + test "send_notification on broken request_stream removes only that stream, not the session" do + 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. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + sleep(0.1) + + # Simulate a broken request_stream. + broken_stream = Object.new + broken_stream.define_singleton_method(:write) { |_data| raise Errno::EPIPE } + broken_stream.define_singleton_method(:close) {} + related_id = "req-1" + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { related_id => broken_stream } + + result = @transport.send_notification("test", { msg: "hello" }, session_id: session_id, related_request_id: related_id) + + refute result + # Session should still exist. + assert @transport.instance_variable_get(:@sessions).key?(session_id) + # The broken request_stream should be removed. + refute @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams].key?(related_id) + # GET SSE stream should still be intact. + assert @transport.instance_variable_get(:@sessions)[session_id][:stream] + end + + test "active_stream does not fall back to GET SSE when related_request_id is given but request_stream is missing" do + 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. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + sleep(0.1) + + # Send notification with a related_request_id that has no matching request_stream. + result = @transport.send_notification( + "test/notify", + { message: "should not arrive" }, + session_id: session_id, + related_request_id: "nonexistent-request-id", + ) + + # Should return false because no matching request_stream exists. + refute result + + # Session should still exist (not cleaned up). + assert @transport.instance_variable_get(:@sessions).key?(session_id) + + # GET SSE stream should NOT have received the notification. + io.rewind + refute_includes io.read, "should not arrive" + end + test "send_notification broadcast continues when one session raises Errno::ECONNRESET" do # Create two sessions. init_request1 = create_rack_request( @@ -1334,8 +1462,7 @@ module MCP assert_nil(body) end - test "send_response_to_stream returns 202 when message is sent to stream" do - # Create and initialize a session + test "POST request returns SSE response even with GET SSE connected" do init_request = create_rack_request( "POST", "/", @@ -1345,7 +1472,7 @@ module MCP init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] - # Connect with SSE + # Connect with GET SSE io = StringIO.new get_request = create_rack_request( "GET", @@ -1354,11 +1481,9 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(io) if response[2].is_a?(Proc) - - # Give the stream time to set up sleep(0.1) - # Make a regular request that will be routed through send_response_to_stream + # POST request should return SSE, not 202 request = create_rack_request( "POST", "/", @@ -1370,9 +1495,13 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 202, response[0] - assert_empty response[1] - assert_empty response[2] + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["Content-Type"] + + post_io = StringIO.new + response[2].call(post_io) + body = JSON.parse(post_io.string.match(/^data: (.+)$/)[1]) + assert_equal "456", body["id"] end test "handle post request with a standard error" do @@ -1436,7 +1565,7 @@ module MCP @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: session_id) end - assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + assert_equal("No active stream for sampling/createMessage request.", error.message) end test "send_request sends via SSE and waits for response" do @@ -1683,6 +1812,300 @@ module MCP assert_equal("SSE session closed while waiting for sampling/createMessage response.", error.message) end + test "send_request sends via POST response stream even with GET SSE connected" do + 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 }, + ) + get_response = @transport.handle_request(get_request) + get_response[2].call(get_io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Set up sampling capability for the session. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .store_client_info(client: { name: "test" }, capabilities: { sampling: {} }) + + # Define a tool that calls create_sampling_message. + sampling_tool = MCP::Tool.define( + name: "sampling_tool", + input_schema: { properties: { prompt: { type: "string" } }, required: ["prompt"] }, + ) do |prompt:, server_context:| + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, + ) + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) + end + @server.tools[sampling_tool.name_value] = sampling_tool + + # Send tools/call via POST (GET SSE is connected). + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "sampling_tool", arguments: { prompt: "Hello" } }, + }.to_json, + ) + + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + response = @transport.handle_request(tool_request) + response[2].call(post_stream) + result_queue.push(:done) + end + + sleep(0.2) + + # Sampling request should be in POST response stream, not GET SSE. + output = post_stream.string + data_lines = output.lines.select { |line| line.start_with?("data: ") } + sampling_request = JSON.parse(data_lines.first.sub("data: ", "")) + assert_equal "sampling/createMessage", sampling_request["method"] + + # GET SSE should NOT have the sampling request. + get_io.rewind + refute_includes get_io.read, "sampling/createMessage" + + # Simulate client sending sampling result via POST. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: sampling_request["id"], + result: { role: "assistant", content: { type: "text", text: "Hi from LLM" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + result_queue.pop + + tool_response_lines = post_stream.string.lines.select { |line| line.start_with?("data: ") } + tool_response = JSON.parse(tool_response_lines.last.sub("data: ", "")) + assert_equal "tool-1", tool_response["id"] + assert_includes tool_response["result"]["content"].first["text"], "Hi from LLM" + end + + test "send_request sends via POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + 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"] + + # Set up sampling capability for the session. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .store_client_info(client: { name: "test" }, capabilities: { sampling: {} }) + + # Define a tool that calls create_sampling_message. + sampling_tool = MCP::Tool.define( + name: "sampling_tool", + input_schema: { properties: { prompt: { type: "string" } }, required: ["prompt"] }, + ) do |prompt:, server_context:| + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, + ) + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) + end + @server.tools[sampling_tool.name_value] = sampling_tool + + # Send tools/call via POST (no GET SSE stream). + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "sampling_tool", arguments: { prompt: "Hello" } }, + }.to_json, + ) + + # Process in background since handle_request blocks until tool completes. + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + response = @transport.handle_request(tool_request) + response[2].call(post_stream) + result_queue.push(:done) + end + + sleep(0.2) # Wait for the tool to start and send sampling request. + + # Read the sampling request from the POST response stream. + output = post_stream.string + data_lines = output.lines.select { |line| line.start_with?("data: ") } + sampling_request = JSON.parse(data_lines.first.sub("data: ", "")) + assert_equal "sampling/createMessage", sampling_request["method"] + + # Simulate client sending sampling result via POST. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: sampling_request["id"], + result: { role: "assistant", content: { type: "text", text: "Hi from LLM" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + result_queue.pop # Wait for tool to complete. + + # Verify the tool result was written to the POST response stream. + tool_response_lines = post_stream.string.lines.select { |line| line.start_with?("data: ") } + tool_response = JSON.parse(tool_response_lines.last.sub("data: ", "")) + assert_equal "tool-1", tool_response["id"] + assert_includes tool_response["result"]["content"].first["text"], "Hi from LLM" + end + + test "send_notification uses POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + 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"] + + # Define a tool that sends a notification during execution. + notification_sent = Queue.new + slow_tool = MCP::Tool.define( + name: "slow_tool", + ) do |server_context:| + server_context.notify_log_message(data: "test log", level: "info") + notification_sent.push(true) + MCP::Tool::Response.new([{ type: "text", text: "done" }]) + end + @server.tools[slow_tool.name_value] = slow_tool + + # Configure logging so notifications are sent. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .configure_logging(MCP::LoggingMessageNotification.new(level: "debug")) + + # Send tools/call via POST (no GET SSE stream). + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "slow_tool", arguments: {} }, + }.to_json, + ) + response = @transport.handle_request(request) + response[2].call(post_stream) + result_queue.push(:done) + end + + notification_sent.pop # Wait for tool to send notification. + result_queue.pop + + # Verify notification was written to the POST response stream. + assert_includes post_stream.string, "notifications/message" + assert_includes post_stream.string, "test log" + end + + test "progress notification uses POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + 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"] + + # Define a tool that reports progress during execution. + progress_reported = Queue.new + progress_tool = MCP::Tool.define( + name: "progress_tool", + ) do |server_context:| + server_context.report_progress(50, total: 100, message: "halfway") + progress_reported.push(true) + MCP::Tool::Response.new([{ type: "text", text: "done" }]) + end + @server.tools[progress_tool.name_value] = progress_tool + + # Send tools/call via POST (no GET SSE stream) with a progress token. + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "token-1" } }, + }.to_json, + ) + response = @transport.handle_request(request) + response[2].call(post_stream) + result_queue.push(:done) + end + + progress_reported.pop + result_queue.pop + + # Verify progress notification was written to the POST response stream. + assert_includes post_stream.string, "notifications/progress" + assert_includes post_stream.string, "token-1" + end + test "POST notifications/initialized returns 202 with no body" do # Create a session first (optional for notification, but keep consistent with flow) init_request = create_rack_request( @@ -2228,13 +2651,16 @@ module MCP params: { name: "log_tool", arguments: {} }, }.to_json, ) - transport.handle_request(tool_request) + tool_response = transport.handle_request(tool_request) + post_io = StringIO.new + tool_response[2].call(post_io) - # Session 1 should receive the log notification. + # Session 1's POST response stream should contain the log notification. + assert_includes post_io.string, "secret" + + # GET SSE streams should NOT receive the log notification. io1.rewind - assert_includes io1.read, "secret" - - # Session 2 should NOT receive the log notification. + refute_includes io1.read, "secret" io2.rewind refute_includes io2.read, "secret" end @@ -2306,13 +2732,16 @@ module MCP }, }.to_json, ) - transport.handle_request(tool_request) + tool_response = transport.handle_request(tool_request) + post_io = StringIO.new + tool_response[2].call(post_io) - # Session 1 should receive the progress notification. + # Session 1's POST response stream should contain the progress notification. + assert_includes post_io.string, "halfway" + + # GET SSE streams should NOT receive the progress notification. io1.rewind - assert_includes io1.read, "halfway" - - # Session 2 should NOT receive the progress notification. + refute_includes io1.read, "halfway" io2.rewind refute_includes io2.read, "halfway" end @@ -2406,7 +2835,8 @@ module MCP params: { level: "error" }, }.to_json, ) - transport.handle_request(set_level1) + response1 = transport.handle_request(set_level1) + response1[2].call(StringIO.new) # Session 2 sets log level to "debug". set_level2 = create_rack_request( @@ -2420,7 +2850,8 @@ module MCP params: { level: "debug" }, }.to_json, ) - transport.handle_request(set_level2) + response2 = transport.handle_request(set_level2) + response2[2].call(StringIO.new) # Session 1 (error level) should not notify for "info", but should for "error". session1_logging = transport.instance_variable_get(:@sessions)[session1][:server_session].logging_message_notification diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 605e385..81735d6 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -46,6 +46,7 @@ module MCP notification_target.expects(:create_sampling_message).with( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, + related_request_id: nil, ).returns({ role: "assistant", content: { type: "text", text: "Hi" } }) context = mock @@ -67,6 +68,7 @@ module MCP context.expects(:create_sampling_message).with( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, + related_request_id: nil, ).returns({ role: "assistant", content: { type: "text", text: "Fallback" } }) progress = Progress.new(notification_target: notification_target, progress_token: nil) diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index 57c488d..bf250e4 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -260,7 +260,7 @@ module MCP max_tokens: 100, ) end - assert_equal("No active SSE stream for sampling/createMessage request.", error_with_sampling.message) + assert_equal("No active stream for sampling/createMessage request.", error_with_sampling.message) # Session without sampling capability should be rejected. session_without_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s2") @@ -290,7 +290,7 @@ module MCP max_tokens: 100, ) end - assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + assert_equal("No active stream for sampling/createMessage request.", error.message) end test "session init does not overwrite server global client_capabilities" do @@ -375,7 +375,18 @@ module MCP max_tokens: 100, ) end - assert_equal("No active SSE stream for sampling/createMessage request.", error.message) + assert_equal("No active stream for sampling/createMessage request.", error.message) + end + + test "Server#create_sampling_message accepts related_request_id without error" do + @server.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + related_request_id: "req-1", + ) + + request = @mock_transport.requests.first + assert_equal "sampling/createMessage", request[:method] end test "create_sampling_message omits nil optional params" do From 65069ed1cc95ed522f8328e54f6db689d804a988 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 03:36:49 +0900 Subject: [PATCH 024/247] Rename `session[:stream]` to `session[:get_sse_stream]` Follow-up to #294. The generic `:stream` key does not convey that it refers to the GET SSE stream, making it harder to distinguish from `session[:post_request_streams]` introduced in #294. --- .../transports/streamable_http_transport.rb | 30 +++++++++---------- .../streamable_http_transport_test.rb | 14 ++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 688e38c..b03b6ce 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -15,7 +15,7 @@ module MCP def initialize(server, stateless: false, session_idle_timeout: nil) super(server) - # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. + # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @mutex = Mutex.new @@ -61,7 +61,7 @@ module MCP end removed_sessions.each do |session| - close_stream_safely(session[:stream]) + close_stream_safely(session[:get_sse_stream]) close_post_request_streams(session) end end @@ -113,7 +113,7 @@ module MCP failed_sessions = [] @sessions.each do |sid, session| - next unless (stream = session[:stream]) + next unless (stream = session[:get_sse_stream]) if session_expired?(session) failed_sessions << sid @@ -247,7 +247,7 @@ module MCP end removed_sessions.each do |session| - close_stream_safely(session[:stream]) + close_stream_safely(session[:get_sse_stream]) close_post_request_streams(session) end end @@ -334,7 +334,7 @@ module MCP end if session - close_stream_safely(session[:stream]) + close_stream_safely(session[:get_sse_stream]) close_post_request_streams(session) end end @@ -358,7 +358,7 @@ module MCP def cleanup_and_collect_stream(session_id, streams_to_close) return unless (removed = cleanup_session_unsafe(session_id)) - streams_to_close << removed[:stream] + streams_to_close << removed[:get_sse_stream] removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream } end @@ -449,7 +449,7 @@ module MCP @mutex.synchronize do @sessions[session_id] = { - stream: nil, + get_sse_stream: nil, server_session: server_session, last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), } @@ -543,7 +543,7 @@ module MCP if related_request_id session.dig(:post_request_streams, related_request_id) else - session[:stream] + session[:get_sse_stream] end end @@ -572,7 +572,7 @@ module MCP end if removed - close_stream_safely(removed[:stream]) + close_stream_safely(removed[:get_sse_stream]) removed[:post_request_streams]&.each_value do |stream| close_stream_safely(stream) @@ -583,7 +583,7 @@ module MCP end def get_session_stream(session_id) - @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) } + @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) } end def session_exists?(session_id) @@ -626,8 +626,8 @@ module MCP def store_stream_for_session(session_id, stream) @mutex.synchronize do session = @sessions[session_id] - if session && !session[:stream] - session[:stream] = stream + if session && !session[:get_sse_stream] + session[:get_sse_stream] = stream else # Either session was removed, or another request already established a stream. stream.close @@ -652,13 +652,13 @@ module MCP end def session_active_with_stream?(session_id) - @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] } + @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] } end def send_keepalive_ping(session_id) @mutex.synchronize do - if @sessions[session_id] && @sessions[session_id][:stream] - send_ping_to_stream(@sessions[session_id][:stream]) + if @sessions[session_id] && @sessions[session_id][:get_sse_stream] + send_ping_to_stream(@sessions[session_id][:get_sse_stream]) end end rescue *STREAM_WRITE_ERRORS => e diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 7435289..473d849 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -346,7 +346,7 @@ module MCP # Simulate an active SSE stream by storing a stream object in the session mock_stream = StringIO.new - @transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream # Attempt a second GET request for the same session get_request = create_rack_request( @@ -377,14 +377,14 @@ module MCP # Establish stream A stream_a = StringIO.new @transport.send(:store_stream_for_session, session_id, stream_a) - assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] # Attempt to store stream B (simulating a racing request) stream_b = StringIO.new @transport.send(:store_stream_for_session, session_id, stream_b) # Stream A should still be the active stream - assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] # Stream B should have been closed assert stream_b.closed? @@ -928,7 +928,7 @@ module MCP end end - @transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream result = @transport.send_notification("test", { message: "test" }, session_id: session_id) @@ -973,7 +973,7 @@ module MCP # The broken request_stream should be removed. refute @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams].key?(related_id) # GET SSE stream should still be intact. - assert @transport.instance_variable_get(:@sessions)[session_id][:stream] + assert @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] end test "active_stream does not fall back to GET SSE when related_request_id is given but request_stream is missing" do @@ -2378,7 +2378,7 @@ module MCP mutex.unlock end end - transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream sleep(0.02) # Wait for session to expire. @@ -2495,7 +2495,7 @@ module MCP # Attach a mock stream to the session stream = StringIO.new - transport.instance_variable_get(:@sessions)[session_id][:stream] = stream + transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = stream # Wait for the session to exceed the idle timeout (0.01s) sleep(0.02) From ee4a7bb4dfcf177f74301bcc09f9b658d671378b Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 12:48:47 +0900 Subject: [PATCH 025/247] [Doc] Move Custom methods to the bottom of the README Custom methods are an advanced feature and may not be appropriate for the top of the README.md. Move them to the bottom of the page. Only sections were reorganized with headings. No content was changed. --- README.md | 104 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 334f021..cbe56a0 100644 --- a/README.md +++ b/README.md @@ -54,57 +54,6 @@ It implements the Model Context Protocol specification, handling model context r - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs - `sampling/createMessage` - Requests LLM completion from the client (server-to-client) -### Custom Methods - -The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: - -```ruby -server = MCP::Server.new(name: "my_server") - -# Define a custom method that returns a result -server.define_custom_method(method_name: "add") do |params| - params[:a] + params[:b] -end - -# Define a custom notification method (returns nil) -server.define_custom_method(method_name: "notify") do |params| - # Process notification - nil -end -``` - -**Key Features:** - -- Accepts any method name as a string -- Block receives the request parameters as a hash -- Can handle both regular methods (with responses) and notifications -- Prevents overriding existing MCP protocol methods -- Supports instrumentation callbacks for monitoring - -**Usage Example:** - -```ruby -# Client request -{ - "jsonrpc": "2.0", - "id": 1, - "method": "add", - "params": { "a": 5, "b": 3 } -} - -# Server response -{ - "jsonrpc": "2.0", - "id": 1, - "result": 8 -} -``` - -**Error Handling:** - -- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method -- Supports the same exception reporting and instrumentation as standard methods - ### Sampling The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. @@ -502,6 +451,59 @@ When configured, sessions that receive no HTTP requests for this duration are au transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) ``` +### Advanced + +#### Custom Methods + +The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Define a custom method that returns a result +server.define_custom_method(method_name: "add") do |params| + params[:a] + params[:b] +end + +# Define a custom notification method (returns nil) +server.define_custom_method(method_name: "notify") do |params| + # Process notification + nil +end +``` + +**Key Features:** + +- Accepts any method name as a string +- Block receives the request parameters as a hash +- Can handle both regular methods (with responses) and notifications +- Prevents overriding existing MCP protocol methods +- Supports instrumentation callbacks for monitoring + +**Usage Example:** + +```ruby +# Client request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "add", + "params": { "a": 5, "b": 3 } +} + +# Server response +{ + "jsonrpc": "2.0", + "id": 1, + "result": 8 +} +``` + +**Error Handling:** + +- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method +- Supports the same exception reporting and instrumentation as standard methods + ### Unsupported Features (to be implemented in future versions) - Resource subscriptions From 63f51bbc26ab56af5e9e583c3b1c189514cb08d9 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 12:58:49 +0900 Subject: [PATCH 026/247] [Doc] Move Usage to the top of the README.md Users are more likely to want to see Usage first, rather than a feature introduction like Sampling. Sampling has been moved below Resource Templates so that Usage appears at the top. Only sections were moved. No content was changed. --- README.md | 910 +++++++++++++++++++++++++++--------------------------- 1 file changed, 455 insertions(+), 455 deletions(-) diff --git a/README.md b/README.md index cbe56a0..4af1d7a 100644 --- a/README.md +++ b/README.md @@ -54,461 +54,6 @@ It implements the Model Context Protocol specification, handling model context r - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs - `sampling/createMessage` - Requests LLM completion from the client (server-to-client) -### Sampling - -The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. -This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. - -**Key Concepts:** - -- **Server-to-Client Request**: Unlike typical MCP methods (client→server), sampling is initiated by the server -- **Client Capability**: Clients must declare `sampling` capability during initialization -- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability -- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs - -**Usage Example (Stdio transport):** - -`Server#create_sampling_message` is for single-client transports (e.g., `StdioTransport`). -For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools instead, -which routes the request to the correct client session. - -```ruby -server = MCP::Server.new(name: "my_server") -transport = MCP::Server::Transports::StdioTransport.new(server) -server.transport = transport -``` - -Client must declare sampling capability during initialization. -This happens automatically when the client connects. - -```ruby -result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What is the capital of France?" } } - ], - max_tokens: 100, - system_prompt: "You are a helpful assistant.", - temperature: 0.7 -) -``` - -Result contains the LLM response: - -```ruby -{ - role: "assistant", - content: { type: "text", text: "The capital of France is Paris." }, - model: "claude-3-sonnet-20240307", - stopReason: "endTurn" -} -``` - -**Parameters:** - -Required: - -- `messages:` (Array) - Array of message objects with `role` and `content` -- `max_tokens:` (Integer) - Maximum tokens in the response - -Optional: - -- `system_prompt:` (String) - System prompt for the LLM -- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) -- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) -- `temperature:` (Float) - Sampling temperature -- `stop_sequences:` (Array) - Sequences that stop generation -- `metadata:` (Hash) - Additional metadata -- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) -- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) - -**Using Sampling in Tools (works with both Stdio and HTTP transports):** - -Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. -The request is automatically routed to the correct client session. -Set `server.server_context = server` so that `server_context.create_sampling_message` delegates to the server: - -```ruby -class SummarizeTool < MCP::Tool - description "Summarize text using LLM" - input_schema( - properties: { - text: { type: "string" } - }, - required: ["text"] - ) - - def self.call(text:, server_context:) - result = server_context.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } - ], - max_tokens: 500 - ) - - MCP::Tool::Response.new([{ - type: "text", - text: result[:content][:text] - }]) - end -end - -server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) -server.server_context = server -``` - -**Tool Use in Sampling:** - -When tools are provided in a sampling request, the LLM can call them during generation. -The server must handle tool calls and continue the conversation with tool results: - -```ruby -result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What's the weather in Paris?" } } - ], - max_tokens: 1000, - tools: [ - { - name: "get_weather", - description: "Get weather for a city", - inputSchema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"] - } - } - ], - tool_choice: { mode: "auto" } -) - -if result[:stopReason] == "toolUse" - tool_results = result[:content].map do |tool_use| - weather_data = get_weather(tool_use[:input][:city]) - - { - type: "tool_result", - toolUseId: tool_use[:id], - content: [{ type: "text", text: weather_data.to_json }] - } - end - - final_result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What's the weather in Paris?" } }, - { role: "assistant", content: result[:content] }, - { role: "user", content: tool_results } - ], - max_tokens: 1000, - tools: [...] - ) -end -``` - -**Error Handling:** - -- Raises `RuntimeError` if transport is not set -- Raises `RuntimeError` if client does not support `sampling` capability -- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability -- Raises `StandardError` if client returns an error response - -### Notifications - -The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. - -#### Notification Methods - -The server provides the following notification methods: - -- `notify_tools_list_changed` - Send a notification when the tools list changes -- `notify_prompts_list_changed` - Send a notification when the prompts list changes -- `notify_resources_list_changed` - Send a notification when the resources list changes -- `notify_log_message` - Send a structured logging notification message - -#### Session Scoping - -When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: - -- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client. -No extra configuration is needed. -- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, -as they represent server-wide state changes. These should be called on the `server` instance directly. - -#### Notification Format - -Notifications follow the JSON-RPC 2.0 specification and use these method names: - -- `notifications/tools/list_changed` -- `notifications/prompts/list_changed` -- `notifications/resources/list_changed` -- `notifications/progress` -- `notifications/message` - -### Progress - -The MCP Ruby SDK supports progress tracking for long-running tool operations, -following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). - -#### How Progress Works - -1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool -2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution -3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress - -#### Server-Side: Tool with Progress - -Tools that accept a `server_context:` parameter can call `report_progress` on it. -The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: - -```ruby -class LongRunningTool < MCP::Tool - description "A tool that reports progress during execution" - input_schema( - properties: { - count: { type: "integer" }, - }, - required: ["count"] - ) - - def self.call(count:, server_context:) - count.times do |i| - # Do work here. - server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}") - end - - MCP::Tool::Response.new([{ type: "text", text: "Done" }]) - end -end -``` - -The `server_context.report_progress` method accepts: - -- `progress` (required) — current progress value (numeric) -- `total:` (optional) — total expected value, so clients can display a percentage -- `message:` (optional) — human-readable status message - -**Key Features:** - -- Tools report progress via `server_context.report_progress` -- `report_progress` is a no-op when no `progressToken` was provided by the client -- Supports both numeric and string progress tokens - -### Completions - -MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), -which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. - -To enable completions, declare the `completions` capability and register a handler: - -```ruby -server = MCP::Server.new( - name: "my_server", - prompts: [CodeReviewPrompt], - resource_templates: [FileTemplate], - capabilities: { completions: {} }, -) - -server.completion_handler do |params| - ref = params[:ref] - argument = params[:argument] - value = argument[:value] - - case ref[:type] - when "ref/prompt" - values = case argument[:name] - when "language" - ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } - else - [] - end - { completion: { values: values, hasMore: false } } - when "ref/resource" - { completion: { values: [], hasMore: false } } - end -end -``` - -The handler receives a `params` hash with: - -- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) -- `argument` - The argument being completed (`{ name: "...", value: "..." }`) -- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) - -The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. -The SDK automatically enforces the 100-item limit per the MCP specification. - -The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. -Requests for unknown references return an error. - -### Logging - -The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -The `notifications/message` notification is used for structured logging between client and server. - -#### Log Levels - -The SDK supports 8 log levels with increasing severity: - -- `debug` - Detailed debugging information -- `info` - General informational messages -- `notice` - Normal but significant events -- `warning` - Warning conditions -- `error` - Error conditions -- `critical` - Critical conditions -- `alert` - Action must be taken immediately -- `emergency` - System is unusable - -#### How Logging Works - -1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level -2. **Server Filtering**: The server only sends log messages at the configured level or higher severity -3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client - -For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. - -For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -**Usage Example:** - -```ruby -server = MCP::Server.new(name: "my_server") -transport = MCP::Server::Transports::StdioTransport.new(server) -server.transport = transport - -# The client first configures the logging level (on the client side): -transport.send_request( - request: { - jsonrpc: "2.0", - method: "logging/setLevel", - params: { level: "info" }, - id: session_id # Unique request ID within the session - } -) - -# Send log messages at different severity levels -server.notify_log_message( - data: { message: "Application started successfully" }, - level: "info" -) - -server.notify_log_message( - data: { message: "Configuration file not found, using defaults" }, - level: "warning" -) - -server.notify_log_message( - data: { - error: "Database connection failed", - details: { host: "localhost", port: 5432 } - }, - level: "error", - logger: "DatabaseLogger" # Optional logger name -) -``` - -**Key Features:** - -- Supports 8 log levels (debug, info, notice, warning, error, critical, alert, emergency) based on https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#log-levels -- Server has capability `logging` to send log messages -- Messages are only sent if a transport is configured -- Messages are filtered based on the client's configured log level -- If the log level hasn't been set by the client, no messages will be sent - -#### Transport Support - -- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout -- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) - -#### Usage Example - -```ruby -server = MCP::Server.new(name: "my_server") - -# Default Streamable HTTP - session oriented -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -server.transport = transport - -# When tools change, notify clients -server.define_tool(name: "new_tool") { |**args| { result: "ok" } } -server.notify_tools_list_changed -``` - -You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. -This mode allows for easy multi-node deployment. -Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`): - -```ruby -# Stateless Streamable HTTP - session-less -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) -``` - -By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds). -When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up: - -```ruby -# Session timeout of 30 minutes -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) -``` - -### Advanced - -#### Custom Methods - -The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: - -```ruby -server = MCP::Server.new(name: "my_server") - -# Define a custom method that returns a result -server.define_custom_method(method_name: "add") do |params| - params[:a] + params[:b] -end - -# Define a custom notification method (returns nil) -server.define_custom_method(method_name: "notify") do |params| - # Process notification - nil -end -``` - -**Key Features:** - -- Accepts any method name as a string -- Block receives the request parameters as a hash -- Can handle both regular methods (with responses) and notifications -- Prevents overriding existing MCP protocol methods -- Supports instrumentation callbacks for monitoring - -**Usage Example:** - -```ruby -# Client request -{ - "jsonrpc": "2.0", - "id": 1, - "method": "add", - "params": { "a": 5, "b": 3 } -} - -# Server response -{ - "jsonrpc": "2.0", - "id": 1, - "result": 8 -} -``` - -**Error Handling:** - -- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method -- Supports the same exception reporting and instrumentation as standard methods - -### Unsupported Features (to be implemented in future versions) - -- Resource subscriptions -- Elicitation - ### Usage > [!IMPORTANT] @@ -1258,6 +803,461 @@ server = MCP::Server.new( ) ``` +### Sampling + +The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. +This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. + +**Key Concepts:** + +- **Server-to-Client Request**: Unlike typical MCP methods (client→server), sampling is initiated by the server +- **Client Capability**: Clients must declare `sampling` capability during initialization +- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability +- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs + +**Usage Example (Stdio transport):** + +`Server#create_sampling_message` is for single-client transports (e.g., `StdioTransport`). +For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools instead, +which routes the request to the correct client session. + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) +server.transport = transport +``` + +Client must declare sampling capability during initialization. +This happens automatically when the client connects. + +```ruby +result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What is the capital of France?" } } + ], + max_tokens: 100, + system_prompt: "You are a helpful assistant.", + temperature: 0.7 +) +``` + +Result contains the LLM response: + +```ruby +{ + role: "assistant", + content: { type: "text", text: "The capital of France is Paris." }, + model: "claude-3-sonnet-20240307", + stopReason: "endTurn" +} +``` + +**Parameters:** + +Required: + +- `messages:` (Array) - Array of message objects with `role` and `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +**Using Sampling in Tools (works with both Stdio and HTTP transports):** + +Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. +The request is automatically routed to the correct client session. +Set `server.server_context = server` so that `server_context.create_sampling_message` delegates to the server: + +```ruby +class SummarizeTool < MCP::Tool + description "Summarize text using LLM" + input_schema( + properties: { + text: { type: "string" } + }, + required: ["text"] + ) + + def self.call(text:, server_context:) + result = server_context.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } + ], + max_tokens: 500 + ) + + MCP::Tool::Response.new([{ + type: "text", + text: result[:content][:text] + }]) + end +end + +server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) +server.server_context = server +``` + +**Tool Use in Sampling:** + +When tools are provided in a sampling request, the LLM can call them during generation. +The server must handle tool calls and continue the conversation with tool results: + +```ruby +result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What's the weather in Paris?" } } + ], + max_tokens: 1000, + tools: [ + { + name: "get_weather", + description: "Get weather for a city", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"] + } + } + ], + tool_choice: { mode: "auto" } +) + +if result[:stopReason] == "toolUse" + tool_results = result[:content].map do |tool_use| + weather_data = get_weather(tool_use[:input][:city]) + + { + type: "tool_result", + toolUseId: tool_use[:id], + content: [{ type: "text", text: weather_data.to_json }] + } + end + + final_result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What's the weather in Paris?" } }, + { role: "assistant", content: result[:content] }, + { role: "user", content: tool_results } + ], + max_tokens: 1000, + tools: [...] + ) +end +``` + +**Error Handling:** + +- Raises `RuntimeError` if transport is not set +- Raises `RuntimeError` if client does not support `sampling` capability +- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability +- Raises `StandardError` if client returns an error response + +### Notifications + +The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. + +#### Notification Methods + +The server provides the following notification methods: + +- `notify_tools_list_changed` - Send a notification when the tools list changes +- `notify_prompts_list_changed` - Send a notification when the prompts list changes +- `notify_resources_list_changed` - Send a notification when the resources list changes +- `notify_log_message` - Send a structured logging notification message + +#### Session Scoping + +When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: + +- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client. +No extra configuration is needed. +- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, +as they represent server-wide state changes. These should be called on the `server` instance directly. + +#### Notification Format + +Notifications follow the JSON-RPC 2.0 specification and use these method names: + +- `notifications/tools/list_changed` +- `notifications/prompts/list_changed` +- `notifications/resources/list_changed` +- `notifications/progress` +- `notifications/message` + +### Progress + +The MCP Ruby SDK supports progress tracking for long-running tool operations, +following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). + +#### How Progress Works + +1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool +2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution +3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress + +#### Server-Side: Tool with Progress + +Tools that accept a `server_context:` parameter can call `report_progress` on it. +The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that reports progress during execution" + input_schema( + properties: { + count: { type: "integer" }, + }, + required: ["count"] + ) + + def self.call(count:, server_context:) + count.times do |i| + # Do work here. + server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}") + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +The `server_context.report_progress` method accepts: + +- `progress` (required) — current progress value (numeric) +- `total:` (optional) — total expected value, so clients can display a percentage +- `message:` (optional) — human-readable status message + +**Key Features:** + +- Tools report progress via `server_context.report_progress` +- `report_progress` is a no-op when no `progressToken` was provided by the client +- Supports both numeric and string progress tokens + +### Completions + +MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), +which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. + +To enable completions, declare the `completions` capability and register a handler: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [CodeReviewPrompt], + resource_templates: [FileTemplate], + capabilities: { completions: {} }, +) + +server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value] + + case ref[:type] + when "ref/prompt" + values = case argument[:name] + when "language" + ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } + else + [] + end + { completion: { values: values, hasMore: false } } + when "ref/resource" + { completion: { values: [], hasMore: false } } + end +end +``` + +The handler receives a `params` hash with: + +- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) +- `argument` - The argument being completed (`{ name: "...", value: "..." }`) +- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) + +The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. +The SDK automatically enforces the 100-item limit per the MCP specification. + +The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. +Requests for unknown references return an error. + +### Logging + +The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +The `notifications/message` notification is used for structured logging between client and server. + +#### Log Levels + +The SDK supports 8 log levels with increasing severity: + +- `debug` - Detailed debugging information +- `info` - General informational messages +- `notice` - Normal but significant events +- `warning` - Warning conditions +- `error` - Error conditions +- `critical` - Critical conditions +- `alert` - Action must be taken immediately +- `emergency` - System is unusable + +#### How Logging Works + +1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level +2. **Server Filtering**: The server only sends log messages at the configured level or higher severity +3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client + +For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. + +For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +**Usage Example:** + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) +server.transport = transport + +# The client first configures the logging level (on the client side): +transport.send_request( + request: { + jsonrpc: "2.0", + method: "logging/setLevel", + params: { level: "info" }, + id: session_id # Unique request ID within the session + } +) + +# Send log messages at different severity levels +server.notify_log_message( + data: { message: "Application started successfully" }, + level: "info" +) + +server.notify_log_message( + data: { message: "Configuration file not found, using defaults" }, + level: "warning" +) + +server.notify_log_message( + data: { + error: "Database connection failed", + details: { host: "localhost", port: 5432 } + }, + level: "error", + logger: "DatabaseLogger" # Optional logger name +) +``` + +**Key Features:** + +- Supports 8 log levels (debug, info, notice, warning, error, critical, alert, emergency) based on https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#log-levels +- Server has capability `logging` to send log messages +- Messages are only sent if a transport is configured +- Messages are filtered based on the client's configured log level +- If the log level hasn't been set by the client, no messages will be sent + +#### Transport Support + +- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout +- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) + +#### Usage Example + +```ruby +server = MCP::Server.new(name: "my_server") + +# Default Streamable HTTP - session oriented +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +server.transport = transport + +# When tools change, notify clients +server.define_tool(name: "new_tool") { |**args| { result: "ok" } } +server.notify_tools_list_changed +``` + +You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. +This mode allows for easy multi-node deployment. +Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`): + +```ruby +# Stateless Streamable HTTP - session-less +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) +``` + +By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds). +When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up: + +```ruby +# Session timeout of 30 minutes +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) +``` + +### Advanced + +#### Custom Methods + +The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Define a custom method that returns a result +server.define_custom_method(method_name: "add") do |params| + params[:a] + params[:b] +end + +# Define a custom notification method (returns nil) +server.define_custom_method(method_name: "notify") do |params| + # Process notification + nil +end +``` + +**Key Features:** + +- Accepts any method name as a string +- Block receives the request parameters as a hash +- Can handle both regular methods (with responses) and notifications +- Prevents overriding existing MCP protocol methods +- Supports instrumentation callbacks for monitoring + +**Usage Example:** + +```ruby +# Client request +{ + "jsonrpc": "2.0", + "id": 1, + "method": "add", + "params": { "a": 5, "b": 3 } +} + +# Server response +{ + "jsonrpc": "2.0", + "id": 1, + "result": 8 +} +``` + +**Error Handling:** + +- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method +- Supports the same exception reporting and instrumentation as standard methods + +### Unsupported Features (to be implemented in future versions) + +- Resource subscriptions +- Elicitation + ## Building an MCP Client The `MCP::Client` class provides an interface for interacting with MCP servers. From f9096347bcf98b2e4b8fa12a6cddfe2cd008b143 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 13:03:46 +0900 Subject: [PATCH 027/247] [Doc] Move stdio before Rails Controller in Usage For Usage, stdio is easier to try first than a Rails controller. Moved stdio to the top and placed Rails Controller after it. Only sections were moved. No content was changed. --- README.md | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4af1d7a..6da1928 100644 --- a/README.md +++ b/README.md @@ -56,43 +56,6 @@ It implements the Model Context Protocol specification, handling model context r ### Usage -> [!IMPORTANT] -> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - -#### Rails Controller - -When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming -[Streamable HTTP](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http) transport -requests. - -You can use `StreamableHTTPTransport#handle_request` to handle requests with proper HTTP -status codes (e.g., 202 Accepted for notifications). - -```ruby -class McpController < ActionController::Base - def create - server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, - ) - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - server.transport = transport - status, headers, body = transport.handle_request(request) - - render(json: body.first, status: status, headers: headers) - end -end -``` - #### Stdio Transport If you want to build a local command-line application, you can use the stdio transport: @@ -140,6 +103,43 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` +#### Rails Controller + +When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming +[Streamable HTTP](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http) transport +requests. + +You can use `StreamableHTTPTransport#handle_request` to handle requests with proper HTTP +status codes (e.g., 202 Accepted for notifications). + +```ruby +class McpController < ActionController::Base + def create + server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, + ) + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + server.transport = transport + status, headers, body = transport.handle_request(request) + + render(json: body.first, status: status, headers: headers) + end +end +``` + +> [!IMPORTANT] +> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, +> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). +> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. + ### Configuration The gem can be configured using the `MCP.configure` block: From 46f6c20cf3ab42fbccc25507259c94237cea09dd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 6 Apr 2026 01:55:27 +0900 Subject: [PATCH 028/247] Release 0.11.0 New features and fixes have been added, and it seems like a good time to cut a release. Subsequent feature proposals can continue to be incorporated into future releases. --- CHANGELOG.md | 14 ++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc8b04..6d7cbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-04-06 + +### Added + +- Support `sampling/createMessage` per MCP specification (#282) +- Support `completion/complete` per MCP specification (#289) + +### Fixed + +- Support POST response SSE streams for server-to-client messages (#294) +- Return protocol errors for invalid arguments and server errors (#285) +- Fix client methods silently swallowing JSON-RPC error responses (#281) +- Close streams outside mutex in session cleanup (#291) + ## [0.10.0] - 2026-03-30 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index c44c5ce..4bc1256 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.10.0" + VERSION = "0.11.0" end From 3f43b1feacbc3bd1709e69ae64d5eac9d6ca1c83 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 03:11:25 +0900 Subject: [PATCH 029/247] [Doc] Restructure sampling section to lead with tool usage Sampling is primarily used within tool execution via `server_context.create_sampling_message`, but the documentation led with the less common direct `Server#create_sampling_message` usage. This ordering could mislead readers into thinking direct usage is the recommended approach. --- README.md | 106 +++++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 6da1928..9a454a9 100644 --- a/README.md +++ b/README.md @@ -810,67 +810,12 @@ This enables servers to leverage the client's LLM capabilities without needing d **Key Concepts:** -- **Server-to-Client Request**: Unlike typical MCP methods (client→server), sampling is initiated by the server +- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server - **Client Capability**: Clients must declare `sampling` capability during initialization - **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability - **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs -**Usage Example (Stdio transport):** - -`Server#create_sampling_message` is for single-client transports (e.g., `StdioTransport`). -For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools instead, -which routes the request to the correct client session. - -```ruby -server = MCP::Server.new(name: "my_server") -transport = MCP::Server::Transports::StdioTransport.new(server) -server.transport = transport -``` - -Client must declare sampling capability during initialization. -This happens automatically when the client connects. - -```ruby -result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What is the capital of France?" } } - ], - max_tokens: 100, - system_prompt: "You are a helpful assistant.", - temperature: 0.7 -) -``` - -Result contains the LLM response: - -```ruby -{ - role: "assistant", - content: { type: "text", text: "The capital of France is Paris." }, - model: "claude-3-sonnet-20240307", - stopReason: "endTurn" -} -``` - -**Parameters:** - -Required: - -- `messages:` (Array) - Array of message objects with `role` and `content` -- `max_tokens:` (Integer) - Maximum tokens in the response - -Optional: - -- `system_prompt:` (String) - System prompt for the LLM -- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) -- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) -- `temperature:` (Float) - Sampling temperature -- `stop_sequences:` (Array) - Sequences that stop generation -- `metadata:` (Hash) - Additional metadata -- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) -- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) - -**Using Sampling in Tools (works with both Stdio and HTTP transports):** +**Using Sampling in Tools:** Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. The request is automatically routed to the correct client session. @@ -905,6 +850,53 @@ server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) server.server_context = server ``` +**Parameters:** + +Required: + +- `messages:` (Array) - Array of message objects with `role` and `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +**Direct Usage:** + +`Server#create_sampling_message` can also be called directly outside of tools: + +```ruby +result = server.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "What is the capital of France?" } } + ], + max_tokens: 100, + system_prompt: "You are a helpful assistant.", + temperature: 0.7 +) +``` + +Result contains the LLM response: + +```ruby +{ + role: "assistant", + content: { type: "text", text: "The capital of France is Paris." }, + model: "claude-3-sonnet-20240307", + stopReason: "endTurn" +} +``` + +For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools +to route the request to the correct client session. + **Tool Use in Sampling:** When tools are provided in a sampling request, the LLM can call them during generation. From 1b45c4382b5d130e053fe28bde6a8bdaad02d6c4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Apr 2026 06:31:25 +0900 Subject: [PATCH 030/247] [Doc] Note sticky session requirement for multi-instance deployments ## Motivation and Context PR #290 documented that `StreamableHTTPTransport` requires a single-process server. However, when running multiple server instances behind a load balancer, requests for the same session must be routed to the same instance since session state is stored in memory. This adds a note about using sticky sessions (session affinity) based on the `Mcp-Session-Id` header. ## How Has This Been Tested? Documentation-only change. ## Breaking Changes None. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6da1928..7ae59f8 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,11 @@ end > so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). > Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that > do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. ### Configuration From 17604beff9740a4ce49508418f900696601f1bba Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Apr 2026 19:03:43 +0900 Subject: [PATCH 031/247] [Doc] Update Rails controller example in README.md The following two points have been updated: ## 1. Use ActionController::API in README controller example `ActionController::Base` includes CSRF protection which rejects POST requests without an authenticity token. MCP clients do not send CSRF tokens, so the controller example should inherit from `ActionController::API` instead. ## 2. Use `stateless: true` for `StreamableHTTPTransport.new` The controller creates a new transport per request, so the session stored on the previous transport is lost. Without `stateless: true`, the second request with `Mcp-Session-Id` returns 404 because the new transport has an empty session map. To share sessions via `Mcp-Session-Id` across requests, there are two approaches. One is persisting the transport in a class variable. The other is mounting the transport as a Rack app via https://github.com/modelcontextprotocol/ruby-sdk/pull/263. Both approaches maintain sessions, so features that depend on `server_context` within the SDK (Progress, Sampling) work correctly. However, per-request user-specific context such as `server_context: { user_id: current_user.id }` cannot be passed since the server is shared across all requests. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6da1928..6a85f34 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ You can use `StreamableHTTPTransport#handle_request` to handle requests with pro status codes (e.g., 202 Accepted for notifications). ```ruby -class McpController < ActionController::Base +class McpController < ActionController::API def create server = MCP::Server.new( name: "my_server", @@ -124,7 +124,8 @@ class McpController < ActionController::Base prompts: [MyPrompt], server_context: { user_id: current_user.id }, ) - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set. + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) server.transport = transport status, headers, body = transport.handle_request(request) From f6c95a0c979ed9a8b78cafa7a2d0395ec6233fb1 Mon Sep 17 00:00:00 2001 From: Eric Amorde Date: Mon, 6 Apr 2026 14:45:56 -0700 Subject: [PATCH 032/247] Fix missing output_schema argument in 'define_tool' API --- lib/mcp/server.rb | 4 ++-- test/mcp/server_test.rb | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index d085b8b..131d1de 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -145,8 +145,8 @@ module MCP end end - def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block) - tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, annotations: annotations, meta: meta, &block) + def define_tool(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, meta: nil, &block) + tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, output_schema: output_schema, annotations: annotations, meta: meta, &block) tool_name = tool.name_value @tool_names << tool_name diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 785e16e..7dfef98 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1127,11 +1127,16 @@ module MCP name: "defined_tool", description: "Defined tool", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + output_schema: { type: "object", properties: { response: { type: "string" } }, required: ["response"] }, meta: { foo: "bar" }, ) do |message:| - Tool::Response.new(message) + Tool::Response.new({ "response" => message }) end + stored_tool = @server.instance_variable_get(:@tools)["defined_tool"] + assert_not_nil(stored_tool) + assert_equal(MCP::Tool::OutputSchema.new({ type: "object", properties: { response: { type: "string" } }, required: ["response"] }), stored_tool.output_schema) + response = @server.handle({ jsonrpc: "2.0", method: "tools/call", @@ -1139,7 +1144,7 @@ module MCP id: 1, }) - assert_equal({ content: "success", isError: false }, response[:result]) + assert_equal({ content: { "response" => "success" }, isError: false }, response[:result]) end test "#define_tool adds a tool with duplicated tool name to the server" do From 81e81c359edf510926e87c1061b213a995bb9b68 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Apr 2026 20:52:50 +0900 Subject: [PATCH 033/247] Validate Content-Type on POST requests ## Motivation and Context The MCP Streamable HTTP specification requires POST request bodies to be JSON-RPC messages with `Content-Type: application/json`. The Ruby SDK did not validate Content-Type, accepting requests with any or no Content-Type. The Python SDK validates this and returns 415 Unsupported Media Type for non-JSON Content-Types. ## How Has This Been Tested? Added tests for Content-Type validation: - POST without Content-Type returns 415 - POST with `Content-Type: text/plain` returns 415 - POST with `Content-Type: application/json; charset=utf-8` succeeds ## Breaking Changes POST requests without `Content-Type: application/json` now return 415 Unsupported Media Type. This is a spec compliance fix. --- .../transports/streamable_http_transport.rb | 15 +++++++ .../streamable_http_transport_test.rb | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 688e38c..88a9a3d 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -267,6 +267,9 @@ module MCP accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES) return accept_error if accept_error + content_type_error = validate_content_type(request) + return content_type_error if content_type_error + body_string = request.body.read session_id = extract_session_id(request) @@ -399,6 +402,18 @@ module MCP end end + def validate_content_type(request) + content_type = request.env["CONTENT_TYPE"] + media_type = content_type&.split(";")&.first&.strip&.downcase + return if media_type == "application/json" + + [ + 415, + { "Content-Type" => "application/json" }, + [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json], + ] + end + def not_acceptable_response(required_types) [ 406, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 7435289..a462050 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1143,6 +1143,51 @@ module MCP assert_equal "Method not allowed", body["error"] end + test "POST request without Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { "HTTP_ACCEPT" => "application/json, text/event-stream" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"] + end + + test "POST request with wrong Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "text/plain", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + end + + test "POST request with Content-Type including charset succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json; charset=utf-8", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + test "POST request without Accept header returns 406" do request = create_rack_request_without_accept( "POST", From f7952e8aafecbc47aca3cfea923948576e35ce54 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 9 Apr 2026 04:52:32 +0900 Subject: [PATCH 034/247] Support customizing the Faraday client in `MCP::Client::HTTP` ## Motivation and Context `MCP::Client::HTTP` builds its Faraday connection internally in a private `client` method with no way to customize the middleware stack or adapter. Users who need observability (request/response recording, failure logging) or a different HTTP adapter must override a private method, coupling to an internal API. This accepts an optional block in `MCP::Client::HTTP.new` that yields the Faraday builder after default middleware is configured, allowing users to add custom middleware or swap the HTTP adapter. ## How Has This Been Tested? Added a test that verifies custom headers set via the block are included in requests. All existing tests continue to pass. ## Breaking Changes None. The block argument is optional, so existing code is unaffected. Resolves #303 --- README.md | 12 ++++++++++++ lib/mcp/client/http.rb | 5 ++++- test/mcp/client/http_test.rb | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dba8f94..741d707 100644 --- a/README.md +++ b/README.md @@ -1405,6 +1405,18 @@ client.tools # will make the call using Bearer auth You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. +#### Customizing the Faraday Connection + +You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. +The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| + faraday.use MyApp::Middleware::HttpRecorder + faraday.adapter :typhoeus +end +``` + ### Tool Objects The client provides a wrapper class for tools returned by the server: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 24c7831..1637b0e 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -7,9 +7,10 @@ module MCP attr_reader :url - def initialize(url:, headers: {}) + def initialize(url:, headers: {}, &block) @url = url @headers = headers + @faraday_customizer = block end def send_request(request:) @@ -78,6 +79,8 @@ module MCP headers.each do |key, value| faraday.headers[key] = value end + + @faraday_customizer&.call(faraday) end end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index fffda7f..081f0e6 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -242,6 +242,31 @@ module MCP assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_block_customizes_faraday_connection + custom_client = HTTP.new(url: url) do |faraday| + faraday.headers["X-Custom"] = "test-value" + end + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url).with( + headers: { + "X-Custom" => "test-value", + "Accept" => "application/json, text/event-stream", + }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + custom_client.send_request(request: request) + end + def test_send_request_raises_error_for_non_json_response request = { jsonrpc: "2.0", From de38804ed79e1acfa2f75173da456a093975b7b9 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 8 Apr 2026 22:25:50 +0900 Subject: [PATCH 035/247] Auto-set `server.transport` in `Transport#initialize` ## Motivation and Context Transport subclasses (StreamableHTTPTransport, StdioTransport) already receive the server in their constructor, but users must manually call `server.transport = transport` after creating a transport instance. This is boilerplate that can be eliminated by setting the connection automatically in `Transport#initialize`. ## How Has This Been Tested? All existing tests pass. Redundant `server.transport = transport` lines were removed from tests, examples, README.md, and conformance server. ## Breaking Changes None. Existing code that manually sets `server.transport = transport` continues to work (idempotent assignment). --- README.md | 4 ---- conformance/server.rb | 8 +------- examples/http_server.rb | 1 - examples/streamable_http_server.rb | 1 - lib/mcp/transport.rb | 1 + test/mcp/progress_test.rb | 1 - .../stdio_notification_integration_test.rb | 1 - ...amable_http_notification_integration_test.rb | 1 - .../streamable_http_transport_test.rb | 4 ---- test/mcp/server_notification_test.rb | 6 ++---- test/mcp/server_progress_test.rb | 1 - test/mcp/server_sampling_test.rb | 17 ++++++----------- 12 files changed, 10 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index dba8f94..c496c7b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,6 @@ class McpController < ActionController::API ) # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set. transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) - server.transport = transport status, headers, body = transport.handle_request(request) render(json: body.first, status: status, headers: headers) @@ -1120,7 +1119,6 @@ For more details, see the [MCP Logging specification](https://modelcontextprotoc ```ruby server = MCP::Server.new(name: "my_server") transport = MCP::Server::Transports::StdioTransport.new(server) -server.transport = transport # The client first configures the logging level (on the client side): transport.send_request( @@ -1174,8 +1172,6 @@ server = MCP::Server.new(name: "my_server") # Default Streamable HTTP - session oriented transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport - # When tools change, notify clients server.define_tool(name: "new_tool") { |**args| { result: "ok" } } server.notify_tools_list_changed diff --git a/conformance/server.rb b/conformance/server.rb index 4bfac2a..0b18858 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -394,7 +394,7 @@ module Conformance def start server = build_server - transport = build_transport(server) + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) configure_handlers(server) rack_app = build_rack_app(transport) @@ -480,12 +480,6 @@ module Conformance ] end - def build_transport(server) - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - server.transport = transport - transport - end - def configure_handlers(server) server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") server.server_context = server diff --git a/examples/http_server.rb b/examples/http_server.rb index 7c0e760..ebe5337 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -94,7 +94,6 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport # Create a logger for MCP-specific logging mcp_logger = Logger.new($stdout) diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index a7857f6..35350d1 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -63,7 +63,6 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport # Create a logger for MCP request/response logging mcp_logger = Logger.new($stdout) diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index 4e3f85f..9f94c60 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -7,6 +7,7 @@ module MCP # Initialize the transport with the server instance def initialize(server) @server = server + server.transport = self end # Send a response to the client diff --git a/test/mcp/progress_test.rb b/test/mcp/progress_test.rb index 491245b..e9552ba 100644 --- a/test/mcp/progress_test.rb +++ b/test/mcp/progress_test.rb @@ -26,7 +26,6 @@ module MCP setup do @server = Server.new(name: "test_server") @transport = MockTransport.new(@server) - @server.transport = @transport @session = ServerSession.new(server: @server, transport: @transport) end diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb index c20fc06..ec67eaf 100644 --- a/test/mcp/server/transports/stdio_notification_integration_test.rb +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -61,7 +61,6 @@ module MCP resources: [], ) @transport = StdioTransport.new(@server) - @server.transport = @transport end teardown do diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 92bb397..355078f 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -15,7 +15,6 @@ module MCP resources: [], ) @transport = StreamableHTTPTransport.new(@server) - @server.transport = @transport end test "server notification methods send SSE notifications through HTTP transport" do diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 473d849..83b8955 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2591,7 +2591,6 @@ module MCP server = Server.new(name: "test", tools: [], prompts: [], resources: []) server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") transport = StreamableHTTPTransport.new(server) - server.transport = transport server.define_tool(name: "log_tool") do |server_context:| server_context.notify_log_message(data: "secret", level: "info") @@ -2668,7 +2667,6 @@ module MCP test "session-scoped progress notification is sent only to the originating session" do server = Server.new(name: "test", tools: [], prompts: [], resources: []) transport = StreamableHTTPTransport.new(server) - server.transport = transport server.define_tool(name: "progress_tool") do |server_context:| server_context.report_progress(50, total: 100, message: "halfway") @@ -2749,7 +2747,6 @@ module MCP test "each session stores its own client info independently" do server = Server.new(name: "test", tools: [], prompts: [], resources: []) transport = StreamableHTTPTransport.new(server) - server.transport = transport # Initialize session 1 with client "alpha". init1 = create_rack_request( @@ -2794,7 +2791,6 @@ module MCP test "each session stores its own logging level independently" do server = Server.new(name: "test", tools: [], prompts: [], resources: []) transport = StreamableHTTPTransport.new(server) - server.transport = transport # Initialize two sessions. init1 = create_rack_request( diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 82bb9bd..74a89cc 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -36,7 +36,6 @@ module MCP ) @mock_transport = MockTransport.new(@server) - @server.transport = @mock_transport end test "#notify_tools_list_changed sends notification through transport" do @@ -122,14 +121,13 @@ module MCP end test "notification methods handle transport errors gracefully" do - # Create a transport that raises errors - error_transport = Class.new(MockTransport) do + # Replace server's transport with one that raises on send_notification. + Class.new(MockTransport) do def send_notification(method, params = nil) raise StandardError, "Transport error" end end.new(@server) - @server.transport = error_transport @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") # Mock the exception reporter diff --git a/test/mcp/server_progress_test.rb b/test/mcp/server_progress_test.rb index f31dfaa..e13fde5 100644 --- a/test/mcp/server_progress_test.rb +++ b/test/mcp/server_progress_test.rb @@ -89,7 +89,6 @@ module MCP ) @mock_transport = MockTransport.new(@server) - @server.transport = @mock_transport @session = ServerSession.new(server: @server, transport: @mock_transport) end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index bf250e4..1526f5f 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -41,7 +41,6 @@ module MCP ) @mock_transport = MockTransport.new(@server) - @server.transport = @mock_transport # Simulate client initialization with sampling capability. @server.handle({ @@ -197,8 +196,8 @@ module MCP test "init with sampling capability allows create_sampling_message" do server = Server.new(name: "test", version: "1.0") - mock_transport = MockTransport.new(server) - server.transport = mock_transport + # Assigns server.transport via Transport#initialize, which create_sampling_message requires. + MockTransport.new(server) server.handle({ jsonrpc: "2.0", @@ -222,8 +221,8 @@ module MCP test "init without capabilities rejects create_sampling_message" do server = Server.new(name: "test", version: "1.0") - mock_transport = MockTransport.new(server) - server.transport = mock_transport + # Assigns server.transport via Transport#initialize, which create_sampling_message requires. + MockTransport.new(server) server.handle({ jsonrpc: "2.0", @@ -247,7 +246,6 @@ module MCP test "create_sampling_message uses per-session capabilities via ServerSession" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) - @server.transport = transport # Session with sampling capability passes validation (fails at send_request due to no stream). session_with_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s1") @@ -277,7 +275,6 @@ module MCP test "ServerSession#client_capabilities falls back to server global capabilities" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) - @server.transport = transport # Session without capabilities stored falls back to @server.client_capabilities. session = ServerSession.new(server: @server, transport: transport, session_id: "s3") @@ -295,8 +292,8 @@ module MCP test "session init does not overwrite server global client_capabilities" do server = Server.new(name: "test", version: "1.0") - mock_transport = MockTransport.new(server) - server.transport = mock_transport + # Assigns server.transport via Transport#initialize, which create_sampling_message requires. + MockTransport.new(server) # Non-session init sets global capabilities. server.handle({ @@ -314,7 +311,6 @@ module MCP # Session-scoped init must NOT overwrite global capabilities. transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - server.transport = transport session = ServerSession.new(server: server, transport: transport, session_id: "s1") server.handle( @@ -340,7 +336,6 @@ module MCP test "Server#create_sampling_message does not see session-scoped capabilities from HTTP init" do server = Server.new(name: "test", version: "1.0") transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - server.transport = transport # HTTP init stores capabilities on the session, not on the server. session = ServerSession.new(server: server, transport: transport, session_id: "s1") From af99a303e1f6a01beeb02b4f685f00f44519e514 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 12 Mar 2026 01:05:13 +0900 Subject: [PATCH 036/247] Make `StreamableHTTPTransport` a Rack application ## Motivation and Context Add `call(env)` to `StreamableHTTPTransport`, making it a Rack application that works with `mount`, `run`, and Rack middleware. Refactor examples to use Rack middleware classes for MCP logging instead of proc wrappers, demonstrating idiomatic Rack composition with the new `run(transport)` pattern. Update README.md with mount and controller integration patterns. Closes #59, #60 ## How Has This Been Tested? Added tests for `call(env)` as a Rack app. All tests pass. ## Breaking Change No breaking changes. All existing APIs are preserved: - `StreamableHTTPTransport.new(server)` continues to work as before. - `handle_request(request)` is unchanged. The new `call(env)` is a purely additive public method. --- AGENTS.md | 4 +- README.md | 31 ++++-- examples/http_server.rb | 81 +++++++------- examples/streamable_http_server.rb | 100 ++++++++--------- .../transports/streamable_http_transport.rb | 18 +++- .../streamable_http_transport_test.rb | 102 ++++++++++++++++++ 6 files changed, 232 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf15786..2b2208b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,6 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing ### Integration patterns -- **Rails controllers**: Use `server.handle_json(request.body.read)` for HTTP endpoints +- **Rails/Rack apps**: Mount `StreamableHTTPTransport` as a Rack app (e.g., `mount transport => "/mcp"`) - **Command-line tools**: Use `StdioTransport.new(server).open` for CLI applications -- **HTTP services**: Use `StreamableHttpTransport` for web-based servers +- **HTTP services**: Use `StreamableHTTPTransport` for web-based servers diff --git a/README.md b/README.md index c496c7b..4f8d162 100644 --- a/README.md +++ b/README.md @@ -103,14 +103,33 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -#### Rails Controller +#### Rails (mount) -When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming -[Streamable HTTP](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http) transport -requests. +`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: -You can use `StreamableHTTPTransport#handle_request` to handle requests with proper HTTP -status codes (e.g., 202 Accepted for notifications). +```ruby +# config/routes.rb +server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], +) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +#### Rails (controller) + +While the mount approach creates a single server at boot time, the controller approach creates a new server per request. +This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). + +`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): ```ruby class McpController < ActionController::API diff --git a/examples/http_server.rb b/examples/http_server.rb index ebe5337..4f3edee 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -95,52 +95,52 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -# Create a logger for MCP-specific logging -mcp_logger = Logger.new($stdout) -mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| - "[MCP] #{msg}\n" -end - -# Create a Rack application with logging -app = proc do |env| - request = Rack::Request.new(env) - - # Log MCP-specific details for POST requests - if request.post? - body = request.body.read - request.body.rewind - begin - parsed_body = JSON.parse(body) - mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") - mcp_logger.debug("Request body: #{JSON.pretty_generate(parsed_body)}") - rescue JSON::ParserError - mcp_logger.warn("Request body (raw): #{body}") - end +# Rack middleware for MCP-specific request/response logging. +class McpRequestLogger + def initialize(app) + @app = app + @logger = Logger.new($stdout) + @logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" } end - # Handle the request - response = transport.handle_request(request) + def call(env) + if env["REQUEST_METHOD"] == "POST" + body = env["rack.input"].read + env["rack.input"].rewind - # Log the MCP response details - _, _, body = response - if body.is_a?(Array) && !body.empty? && body.first - begin - parsed_response = JSON.parse(body.first) - if parsed_response["error"] - mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") - else - mcp_logger.info("Response: #{parsed_response["result"] ? "success" : "empty"} (id: #{parsed_response["id"]})") + begin + parsed = JSON.parse(body) + + @logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})") + @logger.debug("Request body: #{JSON.pretty_generate(parsed)}") + rescue JSON::ParserError + @logger.warn("Request body (raw): #{body}") end - mcp_logger.debug("Response body: #{JSON.pretty_generate(parsed_response)}") - rescue JSON::ParserError - mcp_logger.warn("Response body (raw): #{body}") end - end - response + status, headers, response_body = @app.call(env) + + if response_body.is_a?(Array) && !response_body.empty? && response_body.first + begin + parsed = JSON.parse(response_body.first) + + if parsed["error"] + @logger.error("Response error: #{parsed["error"]["message"]}") + else + @logger.info("Response: #{parsed["result"] ? "success" : "empty"} (id: #{parsed["id"]})") + end + @logger.debug("Response body: #{JSON.pretty_generate(parsed)}") + rescue JSON::ParserError + @logger.warn("Response body (raw): #{response_body}") + end + end + + [status, headers, response_body] + end end -# Wrap the app with Rack middleware +# Build the Rack application with middleware. +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. rack_app = Rack::Builder.new do # Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector) # WARNING: origins("*") allows all origins. Restrict this in production. @@ -158,11 +158,10 @@ rack_app = Rack::Builder.new do # Use CommonLogger for standard HTTP request logging use(Rack::CommonLogger, Logger.new($stdout)) - - # Add other useful middleware use(Rack::ShowExceptions) + use(McpRequestLogger) - run(app) + run(transport) end # Start the server diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index 35350d1..afe48c4 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -64,70 +64,62 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -# Create a logger for MCP request/response logging -mcp_logger = Logger.new($stdout) -mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| - "[MCP] #{msg}\n" -end +# Rack middleware for MCP request/response and SSE logging. +class McpSseLogger + def initialize(app) + @app = app -# Create the Rack application -app = proc do |env| - request = Rack::Request.new(env) + @mcp_logger = Logger.new($stdout) + @mcp_logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" } - # Log request details - if request.post? - body = request.body.read - request.body.rewind - begin - parsed_body = JSON.parse(body) - mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") - - # Log SSE-specific setup - if parsed_body["method"] == "initialize" - sse_logger.info("New client initializing session") - end - rescue JSON::ParserError - mcp_logger.warn("Invalid JSON in request") - end - elsif request.get? - session_id = request.env["HTTP_MCP_SESSION_ID"] || - Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"] - sse_logger.info("SSE connection request for session: #{session_id}") + @sse_logger = Logger.new($stdout) + @sse_logger.formatter = proc { |severity, datetime, _progname, msg| "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" } end - # Handle the request - response = transport.handle_request(request) + def call(env) + if env["REQUEST_METHOD"] == "POST" + body = env["rack.input"].read + env["rack.input"].rewind - # Log response details - status, headers, body = response - if body.is_a?(Array) && !body.empty? && request.post? - begin - parsed_response = JSON.parse(body.first) - if parsed_response["error"] - mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") - elsif parsed_response["accepted"] - # Response was sent via SSE - server.notify_log_message(data: { details: "Response accepted and sent via SSE" }, level: "info") - sse_logger.info("Response sent via SSE stream") - else - mcp_logger.info("Response: success (id: #{parsed_response["id"]})") + begin + parsed = JSON.parse(body) - # Log session ID for initialization - if headers["Mcp-Session-Id"] - sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") + @mcp_logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})") + @sse_logger.info("New client initializing session") if parsed["method"] == "initialize" + rescue JSON::ParserError + @mcp_logger.warn("Invalid JSON in request") + end + elsif env["REQUEST_METHOD"] == "GET" + session_id = env["HTTP_MCP_SESSION_ID"] || Rack::Utils.parse_query(env["QUERY_STRING"])["sessionId"] + + @sse_logger.info("SSE connection request for session: #{session_id}") + end + + status, headers, response_body = @app.call(env) + + if response_body.is_a?(Array) && !response_body.empty? && env["REQUEST_METHOD"] == "POST" + begin + parsed = JSON.parse(response_body.first) + + if parsed["error"] + @mcp_logger.error("Response error: #{parsed["error"]["message"]}") + else + @mcp_logger.info("Response: success (id: #{parsed["id"]})") + @sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") if headers["Mcp-Session-Id"] end + rescue JSON::ParserError + @mcp_logger.warn("Invalid JSON in response") end - rescue JSON::ParserError - mcp_logger.warn("Invalid JSON in response") + elsif env["REQUEST_METHOD"] == "GET" && status == 200 + @sse_logger.info("SSE stream established") end - elsif request.get? && status == 200 - sse_logger.info("SSE stream established") - end - response + [status, headers, response_body] + end end -# Build the Rack application with middleware +# Build the Rack application with middleware. +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. rack_app = Rack::Builder.new do # Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector) # WARNING: origins("*") allows all origins. Restrict this in production. @@ -145,7 +137,9 @@ rack_app = Rack::Builder.new do use(Rack::CommonLogger, Logger.new($stdout)) use(Rack::ShowExceptions) - run(app) + use(McpSseLogger) + + run(transport) end # Print usage instructions diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index b03b6ce..bcbcb1f 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -3,6 +3,15 @@ require "json" require_relative "../../transport" +# This file is autoloaded only when `StreamableHTTPTransport` is referenced, +# so the `rack` dependency does not affect `StdioTransport` users. +begin + require "rack" +rescue LoadError + raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \ + "Add it to your Gemfile: gem 'rack'" +end + module MCP class Server module Transports @@ -39,6 +48,11 @@ module MCP STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze SESSION_REAP_INTERVAL = 60 + # Rack app interface. This transport can be mounted as a Rack app. + def call(env) + handle_request(Rack::Request.new(env)) + end + def handle_request(request) case request.env["REQUEST_METHOD"] when "POST" @@ -531,7 +545,7 @@ module MCP end end - [200, SSE_HEADERS, body] + [200, SSE_HEADERS.dup, body] end # Returns the SSE stream available for server-to-client messages. @@ -613,7 +627,7 @@ module MCP def setup_sse_stream(session_id) body = create_sse_body(session_id) - [200, SSE_HEADERS, body] + [200, SSE_HEADERS.dup, body] end def create_sse_body(session_id) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 83b8955..ff37aff 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2860,6 +2860,108 @@ module MCP assert session2_logging.should_notify?("debug") end + test "call(env) works as a Rack app for POST requests" do + env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init-1" }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + + response = @transport.call(env) + assert_equal 200, response[0] + assert_equal "application/json", response[1]["Content-Type"] + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "init-1", body["id"] + end + + test "call(env) returns 405 for unsupported HTTP methods" do + env = { + "REQUEST_METHOD" => "PUT", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + } + + response = @transport.call(env) + assert_equal 405, response[0] + end + + test "call(env) handles GET SSE stream request" do + init_env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + init_response = @transport.call(init_env) + session_id = init_response[1]["Mcp-Session-Id"] + + get_env = { + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + "HTTP_ACCEPT" => "text/event-stream", + "HTTP_MCP_SESSION_ID" => session_id, + } + + response = @transport.call(get_env) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["Content-Type"] + assert response[2].is_a?(Proc) + end + + test "call(env) handles DELETE session request" do + init_env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + init_response = @transport.call(init_env) + session_id = init_response[1]["Mcp-Session-Id"] + + delete_env = { + "REQUEST_METHOD" => "DELETE", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + "HTTP_MCP_SESSION_ID" => session_id, + } + + response = @transport.call(delete_env) + assert_equal 200, response[0] + + body = JSON.parse(response[2][0]) + assert body["success"] + end + + test "SSE response headers are not frozen so Rack middleware can modify them" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-frozen" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "tools/list", id: "sse-headers-1" }.to_json, + ) + + status, headers, = @transport.handle_request(request) + assert_equal 200, status + assert_equal "text/event-stream", headers["Content-Type"] + refute headers.frozen?, "SSE response headers should not be frozen" + end + private def create_rack_request(method, path, headers, body = nil) From 27c71bb293a2dc6df5134cbe931ae8bef0f87395 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 11 Apr 2026 00:56:32 +0900 Subject: [PATCH 037/247] Release 0.12.0 New features and fixes have been added, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 14 ++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7cbbc..cc50870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-04-11 + +### Added + +- Support customizing the Faraday client in `MCP::Client::HTTP` (#306) + +### Changed + +- Auto-set `server.transport` in `Transport#initialize` (#305) + +### Fixed + +- Validate Content-Type on POST requests (#304) + ## [0.11.0] - 2026-04-06 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 4bc1256..aeddea7 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.11.0" + VERSION = "0.12.0" end From fab6f812617a661033f7e468c253992f61f19706 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 10 Apr 2026 05:05:08 +0900 Subject: [PATCH 038/247] Add `around_request` hook for request instrumentation ## Motivation and Context `instrumentation_callback` fires after request execution, making it impossible to wrap requests with Application Performance Monitoring (APM) spans (e.g. Datadog tracing). This adds `around_request`, which wraps request handling and allows executing code before and after each request. For example, to wrap requests with a Datadog trace: ```ruby MCP.configure do |config| config.around_request = ->(data, &request_handler) { Datadog::Tracing.trace("mcp.#{data[:method]}") do request_handler.call end } end ``` `instrumentation_callback` is soft-deprecated in favor of `around_request`. ## How Has This Been Tested? Added tests for `around_request` in configuration, instrumentation, and server test files. All existing tests continue to pass. ## Breaking Changes `around_request` itself is optional and defaults to a pass-through, and `instrumentation_callback` continues to work as before. However, `instrument_call` now accepts a `server_context:` keyword and its block is invoked without arguments (previously `yield block` passed the block Proc as an argument). This affects anyone mixing `MCP::Instrumentation` into their own classes, though the public `Server` API is unchanged. Resolves #302. --- README.md | 104 +++++++--- lib/mcp/configuration.rb | 40 +++- lib/mcp/instrumentation.rb | 25 ++- lib/mcp/server.rb | 14 +- test/mcp/configuration_test.rb | 47 +++++ test/mcp/instrumentation_test.rb | 336 +++++++++++++++++++++++++++++++ test/mcp/server_test.rb | 257 +++++++++++++++++++++++ 7 files changed, 790 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index a45d333..01c85df 100644 --- a/README.md +++ b/README.md @@ -159,15 +159,17 @@ MCP.configure do |config| end } - config.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") } end ``` or by creating an explicit configuration and passing it into the server. This is useful for systems where an application hosts more than one MCP server but -they might require different instrumentation callbacks. +they might require different configurations. ```ruby configuration = MCP::Configuration.new @@ -179,8 +181,10 @@ configuration.exception_reporter = ->(exception, server_context) { end } -configuration.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" +configuration.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") } server = MCP::Server.new( @@ -193,7 +197,8 @@ server = MCP::Server.new( #### `server_context` -The `server_context` is a user-defined hash that is passed into the server instance and made available to tools, prompts, and exception/instrumentation callbacks. It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. +The `server_context` is a user-defined hash that is passed into the server instance and made available to tool and prompt calls. +It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. **Type:** @@ -210,7 +215,9 @@ server = MCP::Server.new( ) ``` -This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks. +This hash is then passed as the `server_context` keyword argument to tool and prompt calls. +Note that exception and instrumentation callbacks do not receive this user-defined hash. +See the relevant sections below for the arguments they receive. #### Request-specific `_meta` Parameter @@ -263,7 +270,9 @@ end The exception reporter receives: - `exception`: The Ruby exception object that was raised -- `server_context`: The context hash provided to the server +- `server_context`: A hash describing where the failure occurred (e.g., `{ request: }` + for request handling, `{ notification: "tools_list_changed" }` for notification delivery). + This is not the user-defined `server_context` passed to `Server.new`. **Signature:** @@ -271,9 +280,67 @@ The exception reporter receives: exception_reporter = ->(exception, server_context) { ... } ``` -##### Instrumentation Callback +##### Around Request -The instrumentation callback receives a hash with the following possible keys: +The `around_request` hook wraps request handling, allowing you to execute code before and after each request. +This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. + +The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: + +**Signature:** + +```ruby +around_request = ->(data, &request_handler) { request_handler.call } +``` + +**`data` availability by timing:** + +- Before `request_handler.call`: `method` +- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` +- Not available inside `around_request`: `duration` (added after `around_request` returns) + +> [!NOTE] +> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods +> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled +> (for example, `prompt_name` is not recorded when the prompt is not found). +> `duration` is added after `around_request` returns, so it is not visible from within the hook. + +**Example:** + +```ruby +MCP.configure do |config| + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") + } +end +``` + +##### Instrumentation Callback (soft-deprecated) + +> [!NOTE] +> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. +> +> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: +> +> ```ruby +> # Before +> config.instrumentation_callback = ->(data) { log(data) } +> +> # After +> config.around_request = ->(data, &request_handler) do +> request_handler.call +> ensure +> log(data) +> end +> ``` +> +> Note that `data[:duration]` is not available inside `around_request`. +> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. + +The instrumentation callback is called after each request finishes, whether successfully or with an error. +It receives a hash with the following possible keys: - `method`: (String) The protocol method called (e.g., "ping", "tools/list") - `tool_name`: (String, optional) The name of the tool called @@ -284,25 +351,10 @@ The instrumentation callback receives a hash with the following possible keys: - `duration`: (Float) Duration of the call in seconds - `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request -> [!NOTE] -> `tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered. -> This is to avoid potential issues with metric cardinality. - -**Type:** +**Signature:** ```ruby instrumentation_callback = ->(data) { ... } -# where data is a Hash with keys as described above -``` - -**Example:** - -```ruby -MCP.configure do |config| - config.instrumentation_callback = ->(data) { - puts "Instrumentation: #{data.inspect}" - } -end ``` ### Server Protocol Version diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 03105bd..34fcdec 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -7,11 +7,18 @@ module MCP LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", ] - attr_writer :exception_reporter, :instrumentation_callback + attr_writer :exception_reporter, :around_request - def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil, + # @deprecated Use {#around_request=} instead. `instrumentation_callback` + # fires only after a request completes and cannot wrap execution in a + # surrounding block (e.g. for Application Performance Monitoring (APM) spans). + # @see #around_request= + attr_writer :instrumentation_callback + + def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, validate_tool_call_arguments: true) @exception_reporter = exception_reporter + @around_request = around_request @instrumentation_callback = instrumentation_callback @protocol_version = protocol_version if protocol_version @@ -50,10 +57,24 @@ module MCP !@exception_reporter.nil? end + def around_request + @around_request || default_around_request + end + + def around_request? + !@around_request.nil? + end + + # @deprecated Use {#around_request} instead. `instrumentation_callback` + # fires only after a request completes and cannot wrap execution in a + # surrounding block (e.g. for Application Performance Monitoring (APM) spans). + # @see #around_request def instrumentation_callback @instrumentation_callback || default_instrumentation_callback end + # @deprecated Use {#around_request?} instead. + # @see #around_request? def instrumentation_callback? !@instrumentation_callback.nil? end @@ -72,20 +93,30 @@ module MCP else @exception_reporter end + + around_request = if other.around_request? + other.around_request + else + @around_request + end + instrumentation_callback = if other.instrumentation_callback? other.instrumentation_callback else @instrumentation_callback end + protocol_version = if other.protocol_version? other.protocol_version else @protocol_version end + validate_tool_call_arguments = other.validate_tool_call_arguments Configuration.new( exception_reporter: exception_reporter, + around_request: around_request, instrumentation_callback: instrumentation_callback, protocol_version: protocol_version, validate_tool_call_arguments: validate_tool_call_arguments, @@ -111,6 +142,11 @@ module MCP @default_exception_reporter ||= ->(exception, server_context) {} end + def default_around_request + @default_around_request ||= ->(_data, &request_handler) { request_handler.call } + end + + # @deprecated Use {#default_around_request} instead. def default_instrumentation_callback @default_instrumentation_callback ||= ->(data) {} end diff --git a/lib/mcp/instrumentation.rb b/lib/mcp/instrumentation.rb index 40975a9..97c431f 100644 --- a/lib/mcp/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -2,19 +2,40 @@ module MCP module Instrumentation - def instrument_call(method, &block) + def instrument_call(method, server_context: {}, exception_already_reported: nil, &block) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) begin @instrumentation_data = {} add_instrumentation_data(method: method) - result = yield block + result = configuration.around_request.call(@instrumentation_data, &block) result + rescue => e + already_reported = begin + !!exception_already_reported&.call(e) + # rubocop:disable Lint/RescueException + rescue Exception + # rubocop:enable Lint/RescueException + # The predicate is expected to be side-effect-free and return a boolean. + # Any exception raised from it (including non-StandardError such as SystemExit) + # must not shadow the original exception. + false + end + + unless already_reported + add_instrumentation_data(error: :internal_error) unless @instrumentation_data.key?(:error) + configuration.exception_reporter.call(e, server_context) + end + + raise ensure end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) add_instrumentation_data(duration: end_time - start_time) + # Backward compatibility: `instrumentation_callback` is soft-deprecated + # in favor of `around_request`, but existing callers still expect it + # to fire after every request until it is removed in a future version. configuration.instrumentation_callback.call(@instrumentation_data) end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index d085b8b..0cb4f42 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -375,7 +375,7 @@ module MCP def handle_request(request, method, session: nil, related_request_id: nil) handler = @handlers[method] unless handler - instrument_call("unsupported_method") do + instrument_call("unsupported_method", server_context: { request: request }) do client = session&.client || @client add_instrumentation_data(client: client) if client end @@ -385,7 +385,12 @@ module MCP Methods.ensure_capability!(method, capabilities) ->(params) { - instrument_call(method) do + reported_exception = nil + instrument_call( + method, + server_context: { request: request }, + exception_already_reported: ->(e) { reported_exception.equal?(e) }, + ) do result = case method when Methods::INITIALIZE init(params, session: session) @@ -415,11 +420,14 @@ module MCP rescue RequestHandlerError => e report_exception(e.original_error || e, { request: request }) add_instrumentation_data(error: e.error_type) + reported_exception = e raise e rescue => e report_exception(e, { request: request }) add_instrumentation_data(error: :internal_error) - raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) + wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) + reported_exception = wrapped + raise wrapped end } end diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index afd1c0f..8c395f1 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -123,6 +123,53 @@ module MCP refute merged.validate_tool_call_arguments end + test "initializes with a default pass-through around_request" do + config = Configuration.new + called = false + config.around_request.call({}) { called = true } + assert called + end + + test "allows setting a custom around_request" do + config = Configuration.new + call_log = [] + config.around_request = ->(_data, &request_handler) { + call_log << :before + request_handler.call + call_log << :after + } + + config.around_request.call({}) { call_log << :execute } + assert_equal([:before, :execute, :after], call_log) + end + + test "around_request? returns false by default" do + config = Configuration.new + refute config.around_request? + end + + test "around_request? returns true when set" do + config = Configuration.new + config.around_request = ->(_data, &request_handler) { request_handler.call } + assert config.around_request? + end + + test "merge preserves around_request from other config" do + custom = ->(_data, &request_handler) { request_handler.call } + config1 = Configuration.new + config2 = Configuration.new(around_request: custom) + merged = config1.merge(config2) + assert_equal custom, merged.around_request + end + + test "merge preserves around_request from self when other not set" do + custom = ->(_data, &request_handler) { request_handler.call } + config1 = Configuration.new(around_request: custom) + config2 = Configuration.new + merged = config1.merge(config2) + assert_equal custom, merged.around_request + end + test "raises ArgumentError when protocol_version is not a supported value" do exception = assert_raises(ArgumentError) do Configuration.new(protocol_version: "1999-12-31") diff --git a/test/mcp/instrumentation_test.rb b/test/mcp/instrumentation_test.rb index f314d16..ec2e3aa 100644 --- a/test/mcp/instrumentation_test.rb +++ b/test/mcp/instrumentation_test.rb @@ -25,6 +25,19 @@ module MCP add_instrumentation_data(additional_data: "test") end end + + def instrumented_method_with_server_context(context) + instrument_call("instrumented_method_with_server_context", server_context: context) do + # nothing to do + end + end + + def instrumented_method_that_raises_with_error_set + instrument_call("instrumented_method_that_raises_with_error_set") do + add_instrumentation_data(error: :custom_error) + raise "block error" + end + end end test "#instrument_call adds the method name to the instrumentation data" do @@ -46,6 +59,329 @@ module MCP ) end + test "#instrument_call invokes around_request wrapping execution" do + call_log = [] + subject = Subject.new + subject.configuration.around_request = ->(_data, &request_handler) { + call_log << :before + request_handler.call + call_log << :after + } + + subject.instrumented_method + + assert_equal([:before, :after], call_log) + end + + test "#instrument_call around_request receives method before request_handler.call" do + received_method = nil + subject = Subject.new + subject.configuration.around_request = ->(data, &request_handler) { + received_method = data[:method] + request_handler.call + } + + subject.instrumented_method + + assert_equal("instrumented_method", received_method) + end + + test "#instrument_call around_request data is populated after request_handler.call" do + data_after_call = nil + subject = Subject.new + subject.configuration.around_request = ->(data, &request_handler) { + request_handler.call + data_after_call = data.dup + } + + subject.instrumented_method_with_additional_data + + assert_equal("instrumented_method_with_additional_data", data_after_call[:method]) + assert_equal("test", data_after_call[:additional_data]) + end + + test "#instrument_call default around_request is pass-through" do + subject = Subject.new + + subject.instrumented_method_with_additional_data + + assert_equal( + { method: "instrumented_method_with_additional_data", additional_data: "test" }, + subject.instrumentation_data_received.tap { |data| data.delete(:duration) }, + ) + end + + test "#instrument_call reports exception and sets error when around_request raises before request_handler.call" do + reported_exception = nil + reported_context = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(e, server_context) { + reported_exception = e + reported_context = server_context + } + subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom before" } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_equal("boom before", error.message) + assert_same(error, reported_exception) + assert_equal({}, reported_context) + assert_equal(:internal_error, subject.instrumentation_data_received[:error]) + assert(subject.instrumentation_data_received.key?(:duration)) + end + + test "#instrument_call reports exception and sets error when around_request raises after request_handler.call" do + reported_exception = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported_exception = e } + subject.configuration.around_request = ->(_data, &request_handler) { + request_handler.call + raise "boom after" + } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_equal("boom after", error.message) + assert_same(error, reported_exception) + assert_equal(:internal_error, subject.instrumentation_data_received[:error]) + end + + test "#instrument_call preserves block's custom error tag and reports the exception" do + report_count = 0 + subject = Subject.new + subject.configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } + + assert_raises(RuntimeError) { subject.instrumented_method_that_raises_with_error_set } + assert_equal(1, report_count) + assert_equal(:custom_error, subject.instrumentation_data_received[:error]) + end + + test "#instrument_call skips reporting when exception_already_reported returns true" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + error = StandardError.new("inner failure") + subject.define_singleton_method(:instrumented_with_skip) do + instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { error.equal?(e) }) do + raise error + end + end + + assert_raises(StandardError) { subject.instrumented_with_skip } + assert_equal([], reported) + end + + test "#instrument_call reports when exception_already_reported returns false for a new exception" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + target = StandardError.new("inner failure") + subject.configuration.around_request = ->(_data, &request_handler) do + request_handler.call + ensure + raise "ensure boom" + end + + subject.define_singleton_method(:instrumented_with_skip) do + instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { target.equal?(e) }) do + raise target + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_skip } + assert_equal(["ensure boom"], reported) + end + + test "#instrument_call falls back to reporting when exception_already_reported itself raises" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_broken_predicate) do + instrument_call( + "instrumented_with_broken_predicate", + exception_already_reported: ->(_e) { raise "predicate blew up" }, + ) do + raise "original failure" + end + end + + error = assert_raises(RuntimeError) { subject.instrumented_with_broken_predicate } + assert_equal("original failure", error.message) + assert_equal(["original failure"], reported) + end + + test "#instrument_call falls back to reporting when exception_already_reported raises a non-StandardError" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_system_exit_predicate) do + instrument_call( + "instrumented_with_system_exit_predicate", + exception_already_reported: ->(_e) { raise SystemExit, 9 }, + ) do + raise "original failure" + end + end + + error = assert_raises(RuntimeError) { subject.instrumented_with_system_exit_predicate } + assert_equal("original failure", error.message) + assert_equal(["original failure"], reported) + end + + test "#instrument_call normalizes non-boolean truthy return values from exception_already_reported" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_truthy_predicate) do + instrument_call( + "instrumented_with_truthy_predicate", + exception_already_reported: ->(_e) { "non-boolean truthy" }, + ) do + raise "original failure" + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_truthy_predicate } + assert_equal([], reported) + end + + test "#instrument_call reports when exception_already_reported returns nil" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_nil_predicate) do + instrument_call( + "instrumented_with_nil_predicate", + exception_already_reported: ->(_e) { nil }, + ) do + raise "original failure" + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_nil_predicate } + assert_equal(["original failure"], reported) + end + + test "#instrument_call does not carry reported state across invocations when the same exception is reused" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + shared_error = RuntimeError.new("reused") + subject.configuration.around_request = ->(_data, &_request_handler) { raise shared_error } + + 2.times do + assert_raises(RuntimeError) { subject.instrumented_method } + end + assert_equal(["reused", "reused"], reported) + end + + test "#instrument_call reports frozen exceptions without mutating them" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } + + frozen_error = RuntimeError.new("frozen").freeze + subject.configuration.around_request = ->(_data, &_request_handler) { raise frozen_error } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_same(frozen_error, error) + assert_equal([frozen_error], reported) + assert_predicate(frozen_error, :frozen?) + end + + test "#instrument_call nested calls sharing an exception_already_reported predicate report only once" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + reported_exception = nil + predicate = ->(e) { reported_exception.equal?(e) } + + subject.define_singleton_method(:outer) do + instrument_call("outer", exception_already_reported: predicate) { inner } + end + + subject.define_singleton_method(:inner) do + instrument_call("inner", exception_already_reported: predicate) do + raise "nested boom" + rescue => e + configuration.exception_reporter.call(e, {}) + reported_exception = e + raise + end + end + + assert_raises(RuntimeError) { subject.outer } + assert_equal(["nested boom"], reported) + end + + test "#instrument_call keeps reported state isolated across concurrent threads on a shared subject" do + subject = Subject.new + reported = Queue.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + threads = 10.times.map do |i| + Thread.new do + subject.instrument_call("threaded_#{i}") { raise "thread #{i}" } + rescue RuntimeError + # Swallow the intentional raise so the thread finishes cleanly. + # The test verifies side effects via the shared `reported` queue. + end + end + threads.each(&:join) + + messages = [] + messages << reported.pop until reported.empty? + + assert_equal(10.times.map { |i| "thread #{i}" }, messages.sort) + end + + test "#instrument_call reports each invocation when the same exception instance is raised from multiple threads" do + subject = Subject.new + reported = Queue.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } + + shared_error = RuntimeError.new("shared across threads") + start_gate = Queue.new + + threads = 10.times.map do + Thread.new do + start_gate.pop + begin + subject.instrument_call("shared_error_method") { raise shared_error } + rescue RuntimeError + # Swallow the intentional raise so the thread finishes cleanly. + # The test verifies side effects via the shared `reported` queue. + end + end + end + 10.times { start_gate << :go } + threads.each(&:join) + + collected = [] + collected << reported.pop until reported.empty? + + assert_equal(10, collected.size) + assert(collected.all? { |e| e.equal?(shared_error) }) + end + + test "#instrument_call forwards server_context to exception_reporter on around_request failure" do + reported_context = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(_e, server_context) { reported_context = server_context } + subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom" } + + assert_raises(RuntimeError) do + subject.instrumented_method_with_server_context(request: { id: 42 }) + end + assert_equal({ request: { id: 42 } }, reported_context) + end + test "#instrument_call resets the instrumentation data between calls" do subject = Subject.new diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 785e16e..14b9182 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -828,6 +828,263 @@ module MCP assert_instrumentation_data({ method: "notify" }) end + test "#handle tools/call invokes around_request with correct data" do + call_log = [] + data_before = nil + data_after = nil + + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + configuration.around_request = ->(data, &request_handler) { + data_before = data.dup + call_log << :before + request_handler.call + call_log << :after + data_after = data.dup + } + + tool = Tool.define(name: "around_test_tool", description: "Test") do |arg:| + Tool::Response.new([{ type: "text", text: arg }]) + end + + server = Server.new(name: "test_server", tools: [tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "around_test_tool", arguments: { arg: "hello" } }, + id: 1, + } + + server.handle(request) + + assert_equal([:before, :after], call_log) + assert_equal("tools/call", data_before[:method]) + assert_nil(data_before[:tool_name]) + assert_equal("around_test_tool", data_after[:tool_name]) + assert_equal({ arg: "hello" }, data_after[:tool_arguments]) + end + + test "#handle around_request and instrumentation_callback coexist" do + around_called = false + callback_data = nil + + configuration = MCP::Configuration.new + configuration.around_request = ->(_data, &request_handler) { + around_called = true + request_handler.call + } + configuration.instrumentation_callback = ->(data) { + callback_data = data.dup + } + + server = Server.new(name: "test_server", configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + server.handle(request) + + assert(around_called) + assert_equal("ping", callback_data[:method]) + assert(callback_data[:duration]) + end + + test "#handle reports exception and sets error when around_request raises" do + reported_exception = nil + reported_context = nil + callback_data = nil + + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, server_context) { + reported_exception = e + reported_context = server_context + } + configuration.instrumentation_callback = ->(data) { callback_data = data.dup } + configuration.around_request = ->(_data, &_request_handler) { raise "around_request failure" } + + server = Server.new(name: "test_server", configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + response = server.handle(request) + + assert_equal("around_request failure", reported_exception.message) + assert_equal({ request: request }, reported_context) + assert_equal(:internal_error, callback_data[:error]) + assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) + end + + test "#handle does not double-report exception_reporter when a tool handler raises" do + report_count = 0 + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + server.handle(request) + + assert_equal(1, report_count) + end + + test "#handle reports both exceptions when around_request ensure raises after tool failure" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + configuration.around_request = ->(_data, &request_handler) do + request_handler.call + ensure + raise "around ensure boom" + end + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal(["tool failure", "around ensure boom"], reported) + assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) + assert_equal("around ensure boom", response[:error][:data]) + end + + test "#handle reports the same exception object reused across requests on every call" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e } + + shared_error = RuntimeError.new("reused") + shared_tool = Tool.define(name: "shared_failing_tool", description: "Always fails") do + raise shared_error + end + + server = Server.new(name: "test_server", tools: [shared_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "shared_failing_tool", arguments: {} }, + id: 1, + } + + server.handle(request) + server.handle(request) + + assert_equal(2, reported.size) + assert_same(shared_error, reported[0]) + assert_same(shared_error, reported[1]) + end + + test "#handle reports frozen exceptions raised by tool handlers without wrapping them" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e } + + frozen_error = RuntimeError.new("frozen failure").freeze + frozen_tool = Tool.define(name: "frozen_tool", description: "Raises frozen") do + raise frozen_error + end + + server = Server.new(name: "test_server", tools: [frozen_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "frozen_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal([frozen_error], reported) + assert_includes(response[:error][:data], "frozen failure") + end + + test "#handle still reports via exception_reporter when around_request swallows the tool failure" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + configuration.around_request = ->(_data, &request_handler) do + request_handler.call + rescue StandardError + { swallowed: true } + end + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal(["tool failure"], reported) + assert_equal({ swallowed: true }, response[:result]) + end + + test "#handle concurrent requests on a shared server report exceptions independently" do + reported = Queue.new + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + failing_tool = Tool.define(name: "concurrent_tool", description: "Raises per-thread") do |i:| + raise "thread #{i}" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + threads = 10.times.map do |i| + Thread.new do + server.handle({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "concurrent_tool", arguments: { i: i } }, + id: i, + }) + end + end + threads.each(&:join) + + messages = [] + messages << reported.pop until reported.empty? + + assert_equal(10.times.map { |i| "thread #{i}" }.sort, messages.sort) + end + test "#define_custom_method raises an error if the method is already defined" do assert_raises(Server::MethodAlreadyDefinedError) do @server.define_custom_method(method_name: "tools/call") do From 0ad9a89d97b56c0044ebc9cf86c4c72f7390a75c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 11 Apr 2026 14:24:00 +0900 Subject: [PATCH 039/247] Add `_meta` field to resource, content, and result classes ## Motivation and Context MCP 2025-11-25 spec defines a `_meta` general field on many types, but the Ruby SDK only exposed it on `Tool` and `Prompt`. To attach metadata (e.g. MCP Apps CSP hints on resource contents), consumers had to subclass existing classes and override `to_h`. Add a `meta:` keyword argument and `_meta` serialization to: - `Resource::Contents`, `Resource::TextContents`, `Resource::BlobContents` - `Resource`, `ResourceTemplate` - `Content::Text`, `Content::Image`, `Content::Audio`, `Content::EmbeddedResource` - `Tool::Response` (`CallToolResult`) - `Prompt::Result` (`GetPromptResult`) This aligns the Ruby SDK with the Python and TypeScript SDKs, which both expose `_meta` on these types. Resolves #308. ## How Has This Been Tested? Added unit tests for each updated class covering two patterns: `_meta` present (asserts the value is reflected in `to_h[:_meta]`) and `_meta` omitted (asserts no `:_meta` key in `to_h`, i.e., `.compact` behavior). All existing tests continue to pass, and `rake rubocop` is clean. ## Breaking Changes None. All new `meta:` parameters are optional keyword arguments defaulting to `nil`. The `to_h` output stays backward-compatible because `nil` values are removed by the existing `.compact` call, so existing callers require no changes. --- lib/mcp/content.rb | 28 +++++++----- lib/mcp/prompt/result.rb | 7 +-- lib/mcp/resource.rb | 6 ++- lib/mcp/resource/contents.rb | 15 ++++--- lib/mcp/resource_template.rb | 6 ++- lib/mcp/tool/response.rb | 7 +-- test/mcp/content_test.rb | 72 ++++++++++++++++++++++++++++++ test/mcp/prompt/result_test.rb | 41 +++++++++++++++++ test/mcp/resource/contents_test.rb | 72 ++++++++++++++++++++++++++++++ test/mcp/resource_template_test.rb | 17 +++++++ test/mcp/resource_test.rb | 13 ++++++ test/mcp/tool/response_test.rb | 14 ++++++ 12 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 test/mcp/prompt/result_test.rb diff --git a/lib/mcp/content.rb b/lib/mcp/content.rb index 1a1ee03..591ecb4 100644 --- a/lib/mcp/content.rb +++ b/lib/mcp/content.rb @@ -3,56 +3,60 @@ module MCP module Content class Text - attr_reader :text, :annotations + attr_reader :text, :annotations, :meta - def initialize(text, annotations: nil) + def initialize(text, annotations: nil, meta: nil) @text = text @annotations = annotations + @meta = meta end def to_h - { text: text, annotations: annotations, type: "text" }.compact + { text: text, annotations: annotations, _meta: meta, type: "text" }.compact end end class Image - attr_reader :data, :mime_type, :annotations + attr_reader :data, :mime_type, :annotations, :meta - def initialize(data, mime_type, annotations: nil) + def initialize(data, mime_type, annotations: nil, meta: nil) @data = data @mime_type = mime_type @annotations = annotations + @meta = meta end def to_h - { data: data, mimeType: mime_type, annotations: annotations, type: "image" }.compact + { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "image" }.compact end end class Audio - attr_reader :data, :mime_type, :annotations + attr_reader :data, :mime_type, :annotations, :meta - def initialize(data, mime_type, annotations: nil) + def initialize(data, mime_type, annotations: nil, meta: nil) @data = data @mime_type = mime_type @annotations = annotations + @meta = meta end def to_h - { data: data, mimeType: mime_type, annotations: annotations, type: "audio" }.compact + { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "audio" }.compact end end class EmbeddedResource - attr_reader :resource, :annotations + attr_reader :resource, :annotations, :meta - def initialize(resource, annotations: nil) + def initialize(resource, annotations: nil, meta: nil) @resource = resource @annotations = annotations + @meta = meta end def to_h - { resource: resource.to_h, annotations: annotations, type: "resource" }.compact + { resource: resource.to_h, annotations: annotations, _meta: meta, type: "resource" }.compact end end end diff --git a/lib/mcp/prompt/result.rb b/lib/mcp/prompt/result.rb index b55b242..c1fb740 100644 --- a/lib/mcp/prompt/result.rb +++ b/lib/mcp/prompt/result.rb @@ -3,15 +3,16 @@ module MCP class Prompt class Result - attr_reader :description, :messages + attr_reader :description, :messages, :meta - def initialize(description: nil, messages: []) + def initialize(description: nil, messages: [], meta: nil) @description = description @messages = messages + @meta = meta end def to_h - { description: description, messages: messages.map(&:to_h) }.compact + { description: description, messages: messages.map(&:to_h), _meta: meta }.compact end end end diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 51d92e9..990156b 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -5,15 +5,16 @@ require_relative "resource/embedded" module MCP class Resource - attr_reader :uri, :name, :title, :description, :icons, :mime_type + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta - def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil) + def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil) @uri = uri @name = name @title = title @description = description @icons = icons @mime_type = mime_type + @meta = meta end def to_h @@ -24,6 +25,7 @@ module MCP description: description, icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + _meta: meta, }.compact end end diff --git a/lib/mcp/resource/contents.rb b/lib/mcp/resource/contents.rb index 8322d64..a7569b0 100644 --- a/lib/mcp/resource/contents.rb +++ b/lib/mcp/resource/contents.rb @@ -3,23 +3,24 @@ module MCP class Resource class Contents - attr_reader :uri, :mime_type + attr_reader :uri, :mime_type, :meta - def initialize(uri:, mime_type: nil) + def initialize(uri:, mime_type: nil, meta: nil) @uri = uri @mime_type = mime_type + @meta = meta end def to_h - { uri: uri, mimeType: mime_type }.compact + { uri: uri, mimeType: mime_type, _meta: meta }.compact end end class TextContents < Contents attr_reader :text - def initialize(text:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + def initialize(text:, uri:, mime_type:, meta: nil) + super(uri: uri, mime_type: mime_type, meta: meta) @text = text end @@ -31,8 +32,8 @@ module MCP class BlobContents < Contents attr_reader :data - def initialize(data:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + def initialize(data:, uri:, mime_type:, meta: nil) + super(uri: uri, mime_type: mime_type, meta: meta) @data = data end diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index 871600e..b23ecd0 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,15 +2,16 @@ module MCP class ResourceTemplate - attr_reader :uri_template, :name, :title, :description, :icons, :mime_type + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta - def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil) + def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil) @uri_template = uri_template @name = name @title = title @description = description @icons = icons @mime_type = mime_type + @meta = meta end def to_h @@ -21,6 +22,7 @@ module MCP description: description, icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + _meta: meta, }.compact end end diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index 0d9dccb..2532ea8 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -5,9 +5,9 @@ module MCP class Response NOT_GIVEN = Object.new.freeze - attr_reader :content, :structured_content + attr_reader :content, :structured_content, :meta - def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil) + def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) if deprecated_error != NOT_GIVEN warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1) error = deprecated_error @@ -16,6 +16,7 @@ module MCP @content = content || [] @error = error @structured_content = structured_content + @meta = meta end def error? @@ -23,7 +24,7 @@ module MCP end def to_h - { content: content, isError: error?, structuredContent: @structured_content }.compact + { content: content, isError: error?, structuredContent: @structured_content, _meta: meta }.compact end end end diff --git a/test/mcp/content_test.rb b/test/mcp/content_test.rb index f2f5cad..fc2103e 100644 --- a/test/mcp/content_test.rb +++ b/test/mcp/content_test.rb @@ -28,6 +28,19 @@ module MCP refute result.key?(:annotations) end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + image = Image.new("base64data", "image/png", meta: meta) + + assert_equal meta, image.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + image = Image.new("base64data", "image/png") + + refute image.to_h.key?(:_meta) + end end class AudioTest < ActiveSupport::TestCase @@ -53,6 +66,19 @@ module MCP refute result.key?(:annotations) end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + audio = Audio.new("base64data", "audio/wav", meta: meta) + + assert_equal meta, audio.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + audio = Audio.new("base64data", "audio/wav") + + refute audio.to_h.key?(:_meta) + end end class EmbeddedResourceTest < ActiveSupport::TestCase @@ -92,6 +118,52 @@ module MCP refute result.key?(:annotations) end + + test "#to_h with meta" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + embedded = EmbeddedResource.new(resource, meta: meta) + + assert_equal meta, embedded.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + embedded = EmbeddedResource.new(resource) + + refute embedded.to_h.key?(:_meta) + end + end + + class TextTest < ActiveSupport::TestCase + test "#to_h returns correct format per MCP spec" do + text = Text.new("hello") + result = text.to_h + + assert_equal "text", result[:type] + assert_equal "hello", result[:text] + end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + text = Text.new("hello", meta: meta) + + assert_equal meta, text.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + text = Text.new("hello") + + refute text.to_h.key?(:_meta) + end end end end diff --git a/test/mcp/prompt/result_test.rb b/test/mcp/prompt/result_test.rb new file mode 100644 index 0000000..62f161e --- /dev/null +++ b/test/mcp/prompt/result_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Prompt + class ResultTest < ActiveSupport::TestCase + test "#to_h returns description and messages" do + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + ) + + hash = result.to_h + + assert_equal "a prompt", hash[:description] + assert_equal 1, hash[:messages].size + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + meta: meta, + ) + + assert_equal meta, result.to_h[:_meta] + end + + test "#to_h omits _meta when nil" do + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + ) + + refute result.to_h.key?(:_meta) + end + end + end +end diff --git a/test/mcp/resource/contents_test.rb b/test/mcp/resource/contents_test.rb index 53efaa6..f89f8f2 100644 --- a/test/mcp/resource/contents_test.rb +++ b/test/mcp/resource/contents_test.rb @@ -70,6 +70,78 @@ module MCP assert_equal({ uri: "test://binary", blob: "base64data" }, result) refute result.key?(:mimeType) end + + test "Contents#to_h omits _meta when nil" do + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain") + + refute contents.to_h.key?(:_meta) + end + + test "Contents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain", meta: meta) + + assert_equal meta, contents.to_h[:_meta] + end + + test "TextContents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + text_contents = Resource::TextContents.new( + uri: "test://text", + mime_type: "text/plain", + text: "Hello", + meta: meta, + ) + + result = text_contents.to_h + + assert_equal meta, result[:_meta] + assert_equal "Hello", result[:text] + end + + test "TextContents#to_h omits _meta when nil" do + text_contents = Resource::TextContents.new( + uri: "test://text", + mime_type: "text/plain", + text: "Hello", + ) + + refute text_contents.to_h.key?(:_meta) + end + + test "BlobContents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: "image/png", + data: "base64data", + meta: meta, + ) + + result = blob_contents.to_h + + assert_equal meta, result[:_meta] + assert_equal "base64data", result[:blob] + end + + test "BlobContents#to_h omits _meta when nil" do + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: "image/png", + data: "base64data", + ) + + refute blob_contents.to_h.key?(:_meta) + end + + test "Contents#to_h preserves empty _meta hash" do + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain", meta: {}) + + result = contents.to_h + + assert result.key?(:_meta) + assert_equal({}, result[:_meta]) + end end end end diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb index 6b8a565..8d25be0 100644 --- a/test/mcp/resource_template_test.rb +++ b/test/mcp/resource_template_test.rb @@ -36,5 +36,22 @@ module MCP assert_equal expected_icons, resource_template.to_h[:icons] end + + test "#to_h omits _meta when nil" do + resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_meta") + + refute resource_template.to_h.key?(:_meta) + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "template_with_meta", + meta: meta, + ) + + assert_equal meta, resource_template.to_h[:_meta] + end end end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index 9e78012..fb2254d 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -36,5 +36,18 @@ module MCP assert_equal expected_icons, resource.to_h[:icons] end + + test "#to_h omits _meta when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_meta") + + refute resource.to_h.key?(:_meta) + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_meta", meta: meta) + + assert_equal meta, resource.to_h[:_meta] + end end end diff --git a/test/mcp/tool/response_test.rb b/test/mcp/tool/response_test.rb index 6277e18..122dbb0 100644 --- a/test/mcp/tool/response_test.rb +++ b/test/mcp/tool/response_test.rb @@ -122,6 +122,20 @@ module MCP assert_equal structured_content, actual[:structuredContent] refute actual[:isError] end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + response = Response.new([{ type: "text", text: "ok" }], meta: meta) + actual = response.to_h + + assert_equal meta, actual[:_meta] + end + + test "#to_h omits _meta when nil" do + response = Response.new([{ type: "text", text: "ok" }]) + + refute response.to_h.key?(:_meta) + end end end end From a2355727778966880f47b9ad3964f0b0344350ed Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 12 Apr 2026 16:46:40 +0900 Subject: [PATCH 040/247] Remove `Server#create_sampling_message` direct call ## Motivation and Context Sampling is a one-to-one request/response interaction between a server and a specific client. `Server#create_sampling_message` does not scope requests to a specific session, which could lead to unintended broadcasts on multi-client transports. `ServerSession#create_sampling_message` should be used instead, as it routes the request to the correct client session. This aligns with the Python SDK, which also exposes sampling only at the session level. ## How Has This Been Tested? Existing tests updated to call `ServerSession#create_sampling_message` instead of `Server#create_sampling_message`. All tests pass (`rake test`), RuboCop is clean, and conformance tests pass. ## Breaking Changes `Server#create_sampling_message` is removed without a deprecation warning. The sampling feature was released recently and the method was not intended to be the primary API for this feature. --- README.md | 82 +------------ lib/mcp/server.rb | 38 ------ test/mcp/server_sampling_test.rb | 193 ++++--------------------------- 3 files changed, 26 insertions(+), 287 deletions(-) diff --git a/README.md b/README.md index a45d333..cd2ac5d 100644 --- a/README.md +++ b/README.md @@ -823,8 +823,7 @@ This enables servers to leverage the client's LLM capabilities without needing d **Using Sampling in Tools:** Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. -The request is automatically routed to the correct client session. -Set `server.server_context = server` so that `server_context.create_sampling_message` delegates to the server: +The request is automatically routed to the correct client session: ```ruby class SummarizeTool < MCP::Tool @@ -852,7 +851,6 @@ class SummarizeTool < MCP::Tool end server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) -server.server_context = server ``` **Parameters:** @@ -873,86 +871,8 @@ Optional: - `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) - `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) -**Direct Usage:** - -`Server#create_sampling_message` can also be called directly outside of tools: - -```ruby -result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What is the capital of France?" } } - ], - max_tokens: 100, - system_prompt: "You are a helpful assistant.", - temperature: 0.7 -) -``` - -Result contains the LLM response: - -```ruby -{ - role: "assistant", - content: { type: "text", text: "The capital of France is Paris." }, - model: "claude-3-sonnet-20240307", - stopReason: "endTurn" -} -``` - -For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools -to route the request to the correct client session. - -**Tool Use in Sampling:** - -When tools are provided in a sampling request, the LLM can call them during generation. -The server must handle tool calls and continue the conversation with tool results: - -```ruby -result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What's the weather in Paris?" } } - ], - max_tokens: 1000, - tools: [ - { - name: "get_weather", - description: "Get weather for a city", - inputSchema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"] - } - } - ], - tool_choice: { mode: "auto" } -) - -if result[:stopReason] == "toolUse" - tool_results = result[:content].map do |tool_use| - weather_data = get_weather(tool_use[:input][:city]) - - { - type: "tool_result", - toolUseId: tool_use[:id], - content: [{ type: "text", text: weather_data.to_json }] - } - end - - final_result = server.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "What's the weather in Paris?" } }, - { role: "assistant", content: result[:content] }, - { role: "user", content: tool_results } - ], - max_tokens: 1000, - tools: [...] - ) -end -``` - **Error Handling:** -- Raises `RuntimeError` if transport is not set - Raises `RuntimeError` if client does not support `sampling` capability - Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability - Raises `StandardError` if client returns an error response diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index d085b8b..0fb4876 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -206,44 +206,6 @@ module MCP report_exception(e, { notification: "log_message" }) end - # Sends a `sampling/createMessage` request to the client. - # For single-client transports (e.g., `StdioTransport`). For multi-client transports - # (e.g., `StreamableHTTPTransport`), use `ServerSession#create_sampling_message` instead - # to ensure the request is routed to the correct client. - def create_sampling_message( - messages:, - max_tokens:, - system_prompt: nil, - model_preferences: nil, - include_context: nil, - temperature: nil, - stop_sequences: nil, - metadata: nil, - tools: nil, - tool_choice: nil, - related_request_id: nil - ) - unless @transport - raise "Cannot send sampling request without a transport." - end - - params = build_sampling_params( - @client_capabilities, - messages: messages, - max_tokens: max_tokens, - system_prompt: system_prompt, - model_preferences: model_preferences, - include_context: include_context, - temperature: temperature, - stop_sequences: stop_sequences, - metadata: metadata, - tools: tools, - tool_choice: tool_choice, - ) - - @transport.send_request(Methods::SAMPLING_CREATE_MESSAGE, params) - end - # Sets a custom handler for `resources/read` requests. # The block receives the parsed request params and should return resource # contents. The return value is set as the `contents` field of the response. diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index 1526f5f..ffb3539 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -42,21 +42,12 @@ module MCP @mock_transport = MockTransport.new(@server) - # Simulate client initialization with sampling capability. - @server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - capabilities: { sampling: {} }, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) + @session = ServerSession.new(server: @server, transport: @mock_transport) + @session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) end test "create_sampling_message sends request with required params" do - result = @server.create_sampling_message( + result = @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, ) @@ -72,7 +63,7 @@ module MCP end test "create_sampling_message sends all optional params" do - @server.create_sampling_message( + @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, system_prompt: "You are helpful", @@ -94,46 +85,11 @@ module MCP assert_equal({ key: "value" }, params[:metadata]) end - test "create_sampling_message raises error when transport is not set" do - server_without_transport = Server.new(name: "test", version: "1.0") - - # Initialize with sampling capability but no transport. - server_without_transport.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - capabilities: { sampling: {} }, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) - - error = assert_raises(RuntimeError) do - server_without_transport.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - - assert_equal("Cannot send sampling request without a transport.", error.message) - end - test "create_sampling_message raises error when client does not support sampling" do - # Re-initialize without sampling capability. - @server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 2, - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) + @session.store_client_info(client: { name: "test-client" }, capabilities: {}) error = assert_raises(RuntimeError) do - @server.create_sampling_message( + @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, ) @@ -144,7 +100,7 @@ module MCP test "create_sampling_message raises error when tools used but client lacks sampling.tools" do error = assert_raises(RuntimeError) do - @server.create_sampling_message( + @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, tools: [{ name: "test_tool", inputSchema: { type: "object" } }], @@ -156,7 +112,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 - @server.create_sampling_message( + @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, tool_choice: { mode: "auto" }, @@ -167,19 +123,9 @@ module MCP end test "create_sampling_message allows tools when client has sampling.tools capability" do - # Re-initialize with sampling.tools capability. - @server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 3, - params: { - protocolVersion: "2025-11-25", - capabilities: { sampling: { tools: {} } }, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) + @session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: { tools: {} } }) - result = @server.create_sampling_message( + result = @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, tools: [{ name: "test_tool", inputSchema: { type: "object" } }], @@ -194,56 +140,6 @@ module MCP assert_equal "Response from LLM", result[:content][:text] end - test "init with sampling capability allows create_sampling_message" do - server = Server.new(name: "test", version: "1.0") - # Assigns server.transport via Transport#initialize, which create_sampling_message requires. - MockTransport.new(server) - - server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - capabilities: { sampling: { tools: {} } }, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) - - result = server.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - tools: [{ name: "t", inputSchema: { type: "object" } }], - ) - - assert_equal "assistant", result[:role] - end - - test "init without capabilities rejects create_sampling_message" do - server = Server.new(name: "test", version: "1.0") - # Assigns server.transport via Transport#initialize, which create_sampling_message requires. - MockTransport.new(server) - - server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) - - error = assert_raises(RuntimeError) do - server.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - - assert_equal("Client does not support sampling.", error.message) - end - test "create_sampling_message uses per-session capabilities via ServerSession" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) @@ -276,11 +172,23 @@ module MCP test "ServerSession#client_capabilities falls back to server global capabilities" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + # Initialize server with sampling capability. + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + # Session without capabilities stored falls back to @server.client_capabilities. session = ServerSession.new(server: @server, transport: transport, session_id: "s3") transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session } - # Server was initialized with sampling capability in setup, so fallback should pass validation. + # 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" } }], @@ -292,7 +200,7 @@ module MCP test "session init does not overwrite server global client_capabilities" do server = Server.new(name: "test", version: "1.0") - # Assigns server.transport via Transport#initialize, which create_sampling_message requires. + # Assigns server.transport via Transport#initialize. MockTransport.new(server) # Non-session init sets global capabilities. @@ -333,59 +241,8 @@ module MCP assert_equal({}, session.client_capabilities) end - test "Server#create_sampling_message does not see session-scoped capabilities from HTTP init" do - server = Server.new(name: "test", version: "1.0") - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - - # HTTP init stores capabilities on the session, not on the server. - session = ServerSession.new(server: server, transport: transport, session_id: "s1") - server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - capabilities: { sampling: {} }, - clientInfo: { name: "http-client", version: "1.0" }, - }, - }, - session: session, - ) - - # Server-level API should not see session-scoped capabilities. - error = assert_raises(RuntimeError) do - server.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - assert_equal("Client does not support sampling.", error.message) - - # Session-scoped API should work (fails at transport level, not capability). - transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session } - error = assert_raises(RuntimeError) do - session.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - assert_equal("No active stream for sampling/createMessage request.", error.message) - end - - test "Server#create_sampling_message accepts related_request_id without error" do - @server.create_sampling_message( - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - related_request_id: "req-1", - ) - - request = @mock_transport.requests.first - assert_equal "sampling/createMessage", request[:method] - end - test "create_sampling_message omits nil optional params" do - @server.create_sampling_message( + @session.create_sampling_message( messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, system_prompt: nil, From 985fd03ecb0bedf7918bdded5cb89bfbc1c87726 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 6 Apr 2026 17:14:26 +0900 Subject: [PATCH 041/247] Support `elicitation/create` per MCP specification ## Motivation and Context The MCP specification (2025-11-25) defines elicitation as a server-to-client request mechanism that allows servers to request user input during tool execution: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation This aligns with the Python SDK, which also exposes separate methods for form and URL mode elicitation. Three methods are added following the Python SDK's API design: - `create_form_elicitation`: collects structured data via JSON Schema - `create_url_elicitation`: directs users to external URLs for sensitive interactions (OAuth, credentials, etc.) - `notify_elicitation_complete`: notifies the client that a URL mode interaction has completed `URLElicitationRequiredError` (error code `-32042`) is defined as a subclass of `RequestHandlerError` with its own `error_code` and `error_data` attributes. `JsonRpcHandler#handle_request_error` is extended to check for these attributes so that MCP-specific error codes can be propagated without embedding MCP knowledge in the generic JSON-RPC layer. ## How Has This Been Tested? - Unit tests for `ServerSession` form/URL mode, capability checks, nil capabilities, notification, and `URLElicitationRequiredError` - `ServerContext` delegation tests for all three elicitation methods - `JsonRpcHandler` tests for custom error code and fallback behavior - `Methods` capability test for `NOTIFICATIONS_ELICITATION_COMPLETE` - Conformance tests pass (40/40), including `tools-call-elicitation`, `elicitation-sep1034-defaults`, and `elicitation-sep1330-enums` - All tests pass (`rake test`), RuboCop is clean - Verified form mode elicitation end-to-end with a sample MCP server running in Claude Code ## Breaking Changes None. This is a new feature addition. --- README.md | 104 ++++++++++++++- conformance/expected_failures.yml | 6 +- conformance/server.rb | 84 +++++++++--- lib/json_rpc_handler.rb | 23 ++-- lib/mcp/methods.rb | 5 +- lib/mcp/server.rb | 20 ++- lib/mcp/server_context.rb | 36 +++++ lib/mcp/server_session.rb | 29 ++++ test/json_rpc_handler_test.rb | 37 ++++++ test/mcp/methods_test.rb | 1 + test/mcp/server_context_test.rb | 56 ++++++++ test/mcp/server_elicitation_test.rb | 199 ++++++++++++++++++++++++++++ 12 files changed, 565 insertions(+), 35 deletions(-) create mode 100644 test/mcp/server_elicitation_test.rb diff --git a/README.md b/README.md index a45d333..fa24333 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ It implements the Model Context Protocol specification, handling model context r - `resources/templates/list` - Lists all registered resource templates and their schemas - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs - `sampling/createMessage` - Requests LLM completion from the client (server-to-client) +- `elicitation/create` - Requests user input from the client (server-to-client) ### Usage @@ -1085,6 +1086,108 @@ The SDK automatically enforces the 100-item limit per the MCP specification. The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. Requests for unknown references return an error. +### Elicitation + +The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation), +which allows servers to request additional information from users through the client during tool execution. + +Elicitation is a **server-to-client request**. The server sends a request and blocks until the user responds via the client. + +#### Capabilities + +Clients must declare the `elicitation` capability during initialization. The server checks this before sending any elicitation request +and raises a `RuntimeError` if the client does not support it. + +For URL mode support, the client must also declare `elicitation.url` capability. + +#### Using Elicitation in Tools + +Tools that accept a `server_context:` parameter can call `create_form_elicitation` on it: + +```ruby +server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your name", + requested_schema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }]) +end +``` + +#### Form Mode + +Form mode collects structured data from the user directly through the MCP client: + +```ruby +server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your contact information", + requested_schema: { + type: "object", + properties: { + name: { type: "string", description: "Your full name" }, + email: { type: "string", format: "email", description: "Your email address" }, + }, + required: ["name", "email"], + }, + ) + + text = case result[:action] + when "accept" + "Hello, #{result[:content][:name]} (#{result[:content][:email]})" + when "decline" + "User declined" + when "cancel" + "User cancelled" + end + + MCP::Tool::Response.new([{ type: "text", text: text }]) +end +``` + +#### URL Mode + +URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: + +```ruby +server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:| + elicitation_id = SecureRandom.uuid + + result = server_context.create_url_elicitation( + message: "Please authorize access to your GitHub account", + url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}", + elicitation_id: elicitation_id, + ) + + server_context.notify_elicitation_complete(elicitation_id: elicitation_id) + + MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }]) +end +``` + +#### URLElicitationRequiredError + +When a tool cannot proceed until an out-of-band elicitation is completed, raise `MCP::Server::URLElicitationRequiredError`. +This returns a JSON-RPC error with code `-32042` to the client: + +```ruby +server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:| + raise MCP::Server::URLElicitationRequiredError.new([ + { + mode: "url", + elicitationId: SecureRandom.uuid, + url: "https://example.com/oauth/authorize", + message: "GitHub authorization is required.", + }, + ]) +end +``` + ### Logging The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). @@ -1250,7 +1353,6 @@ end ### Unsupported Features (to be implemented in future versions) - Resource subscriptions -- Elicitation ## Building an MCP Client diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index cb7a3fa..03939d5 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,5 +1 @@ -server: - # TODO: Server-to-client requests (elicitation/create) are not implemented. - - tools-call-elicitation - - elicitation-sep1034-defaults - - elicitation-sep1330-enums +server: [] diff --git a/conformance/server.rb b/conformance/server.rb index 0b18858..e6ea6bd 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -178,7 +178,6 @@ module Conformance end end - # TODO: Implement when `Transport` supports server-to-client requests. class TestElicitation < MCP::Tool tool_name "test_elicitation" description "A tool that requests user input from the client" @@ -188,41 +187,96 @@ module Conformance ) class << self - def call(message:) - MCP::Tool::Response.new( - [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h], - error: true, + def call(server_context:, message:) + result = server_context.create_form_elicitation( + message: message, + requested_schema: { + type: "object", + properties: { + username: { type: "string", description: "User's response" }, + email: { type: "string", description: "User's email address" }, + }, + required: ["username", "email"], + }, ) + MCP::Tool::Response.new([MCP::Content::Text.new("User response: #{result}").to_h]) end end end - # TODO: Implement when `Transport` supports server-to-client requests. class TestElicitationSep1034Defaults < MCP::Tool tool_name "test_elicitation_sep1034_defaults" description "A tool that tests elicitation with default values" class << self - def call(**_args) - MCP::Tool::Response.new( - [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h], - error: true, + def call(server_context:, **_args) + result = server_context.create_form_elicitation( + message: "Please provide your information (with defaults)", + requested_schema: { + type: "object", + properties: { + name: { type: "string", default: "John Doe" }, + age: { type: "integer", default: 30 }, + score: { type: "number", default: 95.5 }, + status: { type: "string", enum: ["active", "inactive", "pending"], default: "active" }, + verified: { type: "boolean", default: true }, + }, + }, ) + MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h]) end end end - # TODO: Implement when `Transport` supports server-to-client requests. class TestElicitationSep1330Enums < MCP::Tool tool_name "test_elicitation_sep1330_enums" description "A tool that tests elicitation with enum schemas" class << self - def call(**_args) - MCP::Tool::Response.new( - [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h], - error: true, + def call(server_context:, **_args) + result = server_context.create_form_elicitation( + message: "Please select options", + requested_schema: { + type: "object", + properties: { + untitledSingle: { + type: "string", + enum: ["option1", "option2", "option3"], + }, + titledSingle: { + type: "string", + oneOf: [ + { const: "value1", title: "First Option" }, + { const: "value2", title: "Second Option" }, + { const: "value3", title: "Third Option" }, + ], + }, + legacyEnum: { + type: "string", + enum: ["opt1", "opt2", "opt3"], + enumNames: ["Option One", "Option Two", "Option Three"], + }, + untitledMulti: { + type: "array", + items: { + type: "string", + enum: ["option1", "option2", "option3"], + }, + }, + titledMulti: { + type: "array", + items: { + anyOf: [ + { const: "value1", title: "First Choice" }, + { const: "value2", title: "Second Choice" }, + { const: "value3", title: "Third Choice" }, + ], + }, + }, + }, + }, ) + MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h]) end end end diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index b788e12..d564c78 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -117,20 +117,27 @@ module JsonRpcHandler end def handle_request_error(error, id, id_validation_pattern) - error_type = error.respond_to?(:error_type) ? error.error_type : nil + if error.respond_to?(:error_code) && error.error_code + code = error.error_code + message = error.message + else + error_type = error.respond_to?(:error_type) ? error.error_type : nil - code, message = case error_type - when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"] - when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"] - when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"] - when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"] - else [ErrorCode::INTERNAL_ERROR, "Internal error"] + code, message = case error_type + when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"] + when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"] + when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"] + when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"] + else [ErrorCode::INTERNAL_ERROR, "Internal error"] + end end + data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message + error_response(id: id, id_validation_pattern: id_validation_pattern, error: { code: code, message: message, - data: error.message, + data: data, }) end diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index bf2982f..e0c3744 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -33,6 +33,7 @@ module MCP NOTIFICATIONS_MESSAGE = "notifications/message" NOTIFICATIONS_PROGRESS = "notifications/progress" NOTIFICATIONS_CANCELLED = "notifications/cancelled" + NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete" class MissingRequiredCapabilityError < StandardError attr_reader :method @@ -79,8 +80,8 @@ module MCP require_capability!(method, capabilities, :sampling) when ELICITATION_CREATE require_capability!(method, capabilities, :elicitation) - when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED - # No specific capability required for initialize, ping, progress or cancelled + when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE + # No specific capability required. end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index d085b8b..63f4d5a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -31,14 +31,27 @@ module MCP MAX_COMPLETION_VALUES = 100 class RequestHandlerError < StandardError - attr_reader :error_type - attr_reader :original_error + attr_reader :error_type, :original_error, :error_code, :error_data - def initialize(message, request, error_type: :internal_error, original_error: nil) + def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil) super(message) @request = request @error_type = error_type @original_error = original_error + @error_code = error_code + @error_data = error_data + end + end + + class URLElicitationRequiredError < RequestHandlerError + def initialize(elicitations) + super( + "URL elicitation required", + nil, + error_type: :url_elicitation_required, + error_code: -32042, + error_data: { elicitations: elicitations }, + ) end end @@ -114,7 +127,6 @@ module MCP # No op handlers for currently unsupported methods Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, - Methods::ELICITATION_CREATE => ->(_) {}, } @transport = transport end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index aadd750..656a3fa 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -43,6 +43,42 @@ module MCP end end + # 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. + 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) + elsif @context.respond_to?(:create_form_elicitation) + @context.create_form_elicitation(**kwargs, related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}" + end + end + + # 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. + 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) + elsif @context.respond_to?(:create_url_elicitation) + @context.create_url_elicitation(**kwargs, related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}" + end + end + + # Delegates to the session so the notification is scoped to the originating client. + def notify_elicitation_complete(**kwargs) + if @notification_target.respond_to?(:notify_elicitation_complete) + @notification_target.notify_elicitation_complete(**kwargs) + elsif @context.respond_to?(:notify_elicitation_complete) + @context.notify_elicitation_complete(**kwargs) + else + raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}" + end + end + def method_missing(name, ...) if @context.respond_to?(name) @context.public_send(name, ...) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 2fe8f77..3117a27 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -47,6 +47,35 @@ module MCP 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. + def create_form_elicitation(message:, requested_schema:, related_request_id: nil) + unless client_capabilities&.dig(:elicitation) + raise "Client does not support elicitation. " \ + "The client must declare the `elicitation` capability during initialization." + end + + params = { mode: "form", message: message, requestedSchema: requested_schema } + send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) + end + + # Sends an `elicitation/create` request (URL mode) scoped to this session. + def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil) + unless client_capabilities&.dig(:elicitation, :url) + raise "Client does not support URL mode elicitation. " \ + "The client must declare the `elicitation.url` capability during initialization." + end + + params = { mode: "url", message: message, url: url, elicitationId: elicitation_id } + send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) + end + + # Sends an elicitation complete notification scoped to this session. + def notify_elicitation_complete(elicitation_id:) + send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id }) + rescue => e + @server.report_exception(e, notification: "elicitation_complete") + end + # Sends a progress notification to this session only. def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil) params = { diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 169b1fd..c1f08f1 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -488,6 +488,43 @@ describe JsonRpcHandler do } end + it "returns a custom error code when RequestHandlerError has error_code set" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Custom error", + nil, + error_code: -32042, + error_data: { elicitations: [{ id: "abc" }] }, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32042, + message: "Custom error", + data: { elicitations: [{ id: "abc" }] }, + } + end + + it "falls back to error_type when error_code is nil" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Invalid params error", + nil, + error_type: :invalid_params, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32602, + message: "Invalid params", + data: "Invalid params error", + } + end + # 6 Batch # # To send several Request objects at the same time, the Client MAY send an Array filled with Request objects. diff --git a/test/mcp/methods_test.rb b/test/mcp/methods_test.rb index 230fb31..1424a41 100644 --- a/test/mcp/methods_test.rb +++ b/test/mcp/methods_test.rb @@ -81,5 +81,6 @@ module MCP ensure_capability_does_not_raise_for Methods::PING ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_PROGRESS ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_CANCELLED + ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ELICITATION_COMPLETE end end diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 81735d6..01b8f0b 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -83,6 +83,62 @@ module MCP assert_equal "Fallback", result[:content][:text] end + test "ServerContext#create_form_elicitation delegates to notification_target" do + notification_target = mock + notification_target.expects(:create_form_elicitation).with( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + related_request_id: nil, + ).returns(action: "accept", content: { name: "test_user" }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_form_elicitation( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + + assert_equal "accept", result[:action] + end + + test "ServerContext#create_url_elicitation delegates to notification_target" do + notification_target = mock + notification_target.expects(:create_url_elicitation).with( + message: "Please authorize", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + related_request_id: nil, + ).returns(action: "accept") + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_url_elicitation( + message: "Please authorize", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + + assert_equal "accept", result[:action] + end + + test "ServerContext#notify_elicitation_complete delegates to notification_target" do + notification_target = mock + notification_target.expects(:notify_elicitation_complete).with(elicitation_id: "abc-123") + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + server_context.notify_elicitation_complete(elicitation_id: "abc-123") + end + test "ServerContext delegates to custom object context" do context = Object.new def context.custom_method diff --git a/test/mcp/server_elicitation_test.rb b/test/mcp/server_elicitation_test.rb new file mode 100644 index 0000000..82dd414 --- /dev/null +++ b/test/mcp/server_elicitation_test.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerElicitationTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + class MockTransport < Transport + attr_reader :requests, :notifications + + def initialize(server) + super + @requests = [] + @notifications = [] + end + + def send_request(method, params = nil) + @requests << { method: method, params: params } + { action: "accept" } + end + + def send_response(response); end + + def send_notification(method, params = nil) + @notifications << { method: method, params: params } + end + + def open; end + def close; end + def handle_request(request); end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: configuration, + ) + + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + + @session = ServerSession.new(server: @server, transport: @mock_transport) + @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) + end + + test "#create_form_elicitation sends request through transport" do + result = @session.create_form_elicitation( + message: "Please provide your name", + requested_schema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + ) + + assert_equal 1, @mock_transport.requests.size + + request = @mock_transport.requests.first + + assert_equal Methods::ELICITATION_CREATE, request[:method] + assert_equal "form", request[:params][:mode] + assert_equal "Please provide your name", request[:params][:message] + assert_equal "object", request[:params][:requestedSchema][:type] + assert_equal({ action: "accept" }, result) + end + + test "#create_form_elicitation raises error when client does not support elicitation" do + @session.instance_variable_set(:@client_capabilities, {}) + + error = assert_raises(RuntimeError) do + @session.create_form_elicitation( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + + assert_equal( + "Client does not support elicitation. The client must declare the `elicitation` capability during initialization.", + error.message, + ) + end + + test "#create_form_elicitation raises error when client capabilities are nil" do + @session.instance_variable_set(:@client_capabilities, nil) + @server.instance_variable_set(:@client_capabilities, nil) + + error = assert_raises(RuntimeError) do + @session.create_form_elicitation( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + + assert_equal( + "Client does not support elicitation. The client must declare the `elicitation` capability during initialization.", + error.message, + ) + end + + test "#create_url_elicitation sends url mode request through transport" do + @session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } }) + + result = @session.create_url_elicitation( + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + + assert_equal 1, @mock_transport.requests.size + + request = @mock_transport.requests.first + + assert_equal Methods::ELICITATION_CREATE, request[:method] + assert_equal "url", request[:params][:mode] + assert_equal "Please authorize access", request[:params][:message] + assert_equal "https://example.com/oauth", request[:params][:url] + assert_equal "abc-123", request[:params][:elicitationId] + assert_equal({ action: "accept" }, result) + end + + test "#create_url_elicitation raises error when client does not support url mode" do + error = assert_raises(RuntimeError) do + @session.create_url_elicitation( + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + end + + assert_equal( + "Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.", + error.message, + ) + end + + test "#create_url_elicitation raises error when client capabilities are nil" do + @session.instance_variable_set(:@client_capabilities, nil) + @server.instance_variable_set(:@client_capabilities, nil) + + error = assert_raises(RuntimeError) do + @session.create_url_elicitation( + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + end + + assert_equal( + "Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.", + error.message, + ) + end + + test "#notify_elicitation_complete sends notification through transport" do + @session.notify_elicitation_complete(elicitation_id: "abc-123") + + assert_equal 1, @mock_transport.notifications.size + + notification = @mock_transport.notifications.first + + assert_equal Methods::NOTIFICATIONS_ELICITATION_COMPLETE, notification[:method] + assert_equal "abc-123", notification[:params][:elicitationId] + end + + test "URLElicitationRequiredError can be raised from a tool handler" do + @server.define_tool(name: "needs_auth", description: "Needs OAuth") do + raise Server::URLElicitationRequiredError, [ + { mode: "url", elicitationId: "abc-123", url: "https://example.com/oauth", message: "Auth required" }, + ] + end + + response = JSON.parse(@server.handle_json(JSON.generate({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "needs_auth", arguments: {} }, + }))) + + assert_equal(-32042, response["error"]["code"]) + assert_equal "URL elicitation required", response["error"]["message"] + + elicitations = response["error"]["data"]["elicitations"] + + assert_equal 1, elicitations.size + assert_equal "abc-123", elicitations.first["elicitationId"] + + assert_instrumentation_data( + method: "tools/call", tool_name: "needs_auth", tool_arguments: {}, error: :url_elicitation_required, + ) + end + end +end From 1ddd26b10bdcde5dccebbc341719817cf87e5b93 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Apr 2026 19:38:44 +0900 Subject: [PATCH 042/247] Remove redundant `respond_to?(:flush)` guard in `StreamableHTTPTransport` ## Motivation and Context The stream object always responds to flush, so the conditional check is unnecessary. ## How Has This Been Tested? All existing tests are passing. ## Breaking Changes None. --- lib/mcp/server/transports/streamable_http_transport.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 688e38c..576b099 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -255,12 +255,12 @@ module MCP def send_to_stream(stream, data) message = data.is_a?(String) ? data : data.to_json stream.write("data: #{message}\n\n") - stream.flush if stream.respond_to?(:flush) + stream.flush end def send_ping_to_stream(stream) stream.write(": ping #{Time.now.iso8601}\n\n") - stream.flush if stream.respond_to?(:flush) + stream.flush end def handle_post(request) From eeb130171d77ea3a03b971b848a4f1e073ccc278 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 14 Apr 2026 11:41:15 +0900 Subject: [PATCH 043/247] Add `roots/list` and `notifications/roots/list_changed` support ## Motivation and Context MCP spec (2025-11-25) defines Roots as a mechanism for clients to expose filesystem boundaries (directories and files) to servers: https://modelcontextprotocol.io/specification/2025-11-25/client/roots This enables servers to understand which parts of the filesystem they can operate on. This adds support for the two roots protocol interactions: - `roots/list` -- server-to-client request to retrieve the client's filesystem roots - `notifications/roots/list_changed` -- client-to-server notification when roots change `roots/list` is a one-to-one request between a server and a specific client, so `list_roots` is exposed only at the session level (`ServerSession#list_roots`), matching the Python SDK approach and aligning with PR #311 for sampling. `Server#on_roots_list_changed` registers a callback for the client notification, which is server-wide and does not require session scoping. Also fixes `notifications/roots/list_changed` capability checking: as a client-originated notification, it should not require server-side capability declaration. ## How Has This Been Tested? New test file `test/mcp/server_roots_test.rb` with 8 test cases covering session-scoped `list_roots`, capability validation and fallback, `on_roots_list_changed` callback registration, and client capability storage. Updated `test/mcp/methods_test.rb` to reflect the corrected capability checks. All tests pass (`rake test`), RuboCop is clean. ## Breaking Changes None. --- README.md | 66 +++++++++++ lib/mcp/methods.rb | 6 +- lib/mcp/server.rb | 9 ++ lib/mcp/server_context.rb | 9 ++ lib/mcp/server_session.rb | 9 ++ test/mcp/methods_test.rb | 5 +- test/mcp/server_context_test.rb | 26 ++++ test/mcp/server_roots_test.rb | 202 ++++++++++++++++++++++++++++++++ 8 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 test/mcp/server_roots_test.rb diff --git a/README.md b/README.md index a63d75f..9113304 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports resource registration and retrieval - Supports stdio & Streamable HTTP (including SSE) transports - Supports notifications for list changes (tools, prompts, resources) +- Supports roots (server-to-client filesystem boundary queries) - Supports sampling (server-to-client LLM completion requests) ### Supported Methods @@ -52,6 +53,7 @@ It implements the Model Context Protocol specification, handling model context r - `resources/read` - Retrieves a specific resource by name - `resources/templates/list` - Lists all registered resource templates and their schemas - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `roots/list` - Requests filesystem roots from the client (server-to-client) - `sampling/createMessage` - Requests LLM completion from the client (server-to-client) - `elicitation/create` - Requests user input from the client (server-to-client) @@ -861,6 +863,70 @@ server = MCP::Server.new( ) ``` +### Roots + +The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. +Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available. + +**Key Concepts:** + +- **Server-to-Client Request**: Like sampling, roots listing is initiated by the server +- **Client Capability**: Clients must declare `roots` capability during initialization +- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change + +**Using Roots in Tools:** + +Tools that accept a `server_context:` parameter can call `list_roots` on it. +The request is automatically routed to the correct client session: + +```ruby +class FileSearchTool < MCP::Tool + description "Search files within the client's project roots" + input_schema( + properties: { + query: { type: "string" } + }, + required: ["query"] + ) + + def self.call(query:, server_context:) + roots = server_context.list_roots + root_uris = roots[:roots].map { |root| root[:uri] } + + MCP::Tool::Response.new([{ + type: "text", + text: "Searching in roots: #{root_uris.join(", ")}" + }]) + end +end +``` + +Result contains an array of root objects: + +```ruby +{ + roots: [ + { uri: "file:///home/user/projects/myproject", name: "My Project" }, + { uri: "file:///home/user/repos/backend", name: "Backend Repository" } + ] +} +``` + +**Handling Root Changes:** + +Register a callback to be notified when the client's roots change: + +```ruby +server.roots_list_changed_handler do + puts "Client's roots have changed, tools will see updated roots on next call." +end +``` + +**Error Handling:** + +- Raises `RuntimeError` if client does not support `roots` capability +- Raises `StandardError` if client returns an error response + ### Sampling The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index e0c3744..0af5e41 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -73,14 +73,12 @@ module MCP require_capability!(method, capabilities, :completions) when ROOTS_LIST require_capability!(method, capabilities, :roots) - when NOTIFICATIONS_ROOTS_LIST_CHANGED - require_capability!(method, capabilities, :roots) - require_capability!(method, capabilities, :roots, :listChanged) when SAMPLING_CREATE_MESSAGE require_capability!(method, capabilities, :sampling) when ELICITATION_CREATE require_capability!(method, capabilities, :elicitation) - when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE + when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, + NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE # No specific capability required. end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index b743479..e1fd2c4 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -121,6 +121,7 @@ module MCP Methods::PING => ->(_) { {} }, Methods::NOTIFICATIONS_INITIALIZED => ->(_) {}, Methods::NOTIFICATIONS_PROGRESS => ->(_) {}, + Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {}, Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT }, Methods::LOGGING_SET_LEVEL => method(:configure_logging_level), @@ -218,6 +219,14 @@ module MCP report_exception(e, { notification: "log_message" }) end + # Sets a handler for `notifications/roots/list_changed` notifications. + # Called when a client notifies the server that its filesystem roots have changed. + # + # @yield [params] The notification params (typically `nil`). + def roots_list_changed_handler(&block) + @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block + end + # Sets a custom handler for `resources/read` requests. # The block receives the parsed request params and should return resource # contents. The return value is set as the `contents` field of the response. diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 656a3fa..f572307 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -30,6 +30,15 @@ module MCP @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end + # Delegates to the session so the request is scoped to the originating client. + def list_roots + if @notification_target.respond_to?(:list_roots) + @notification_target.list_roots(related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'list_roots' for #{self}" + end + end + # 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. diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 3117a27..7d8c993 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -41,6 +41,15 @@ module MCP @client_capabilities || @server.client_capabilities end + # Sends a `roots/list` request scoped to this session. + def list_roots(related_request_id: nil) + unless client_capabilities&.dig(:roots) + raise "Client does not support roots." + end + + send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id) + end + # Sends a `sampling/createMessage` request scoped to this session. def create_sampling_message(related_request_id: nil, **kwargs) params = @server.build_sampling_params(client_capabilities, **kwargs) diff --git a/test/mcp/methods_test.rb b/test/mcp/methods_test.rb index 1424a41..1be8cc6 100644 --- a/test/mcp/methods_test.rb +++ b/test/mcp/methods_test.rb @@ -68,10 +68,7 @@ module MCP ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_INITIALIZED ensure_capability_raises_error_for Methods::ROOTS_LIST, required_capability_name: "roots" - ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED, required_capability_name: "roots" - ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED, - required_capability_name: "roots.listChanged", - capabilities: { roots: {} } + ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED ensure_capability_raises_error_for Methods::SAMPLING_CREATE_MESSAGE, required_capability_name: "sampling" diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 01b8f0b..b687434 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -41,6 +41,32 @@ module MCP assert_raises(NoMethodError) { server_context.nonexistent_method } end + test "ServerContext#list_roots delegates to notification_target" do + notification_target = mock + notification_target.expects(:list_roots).with( + related_request_id: nil, + ).returns({ roots: [{ uri: "file:///project", name: "Project" }] }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.list_roots + + assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots] + end + + test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do + notification_target = mock + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + assert_raises(NoMethodError) { server_context.list_roots } + end + test "ServerContext#create_sampling_message delegates to notification_target over context" do notification_target = mock notification_target.expects(:create_sampling_message).with( diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb new file mode 100644 index 0000000..c887398 --- /dev/null +++ b/test/mcp/server_roots_test.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerRootsTest < ActiveSupport::TestCase + class MockTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil) + @requests << { method: method, params: params } + { + roots: [ + { uri: "file:///home/user/projects/myproject", name: "My Project" }, + ], + } + end + + def send_response(response); end + def send_notification(method, params = nil); end + def open; end + def close; end + end + + setup do + @server = Server.new( + name: "test_server", + version: "1.0.0", + ) + + @mock_transport = MockTransport.new(@server) + + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + end + + test "roots_list_changed_handler registers callback invoked when notification received" do + callback_called = false + + @server.roots_list_changed_handler do |_params| + callback_called = true + end + + @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + + assert callback_called + end + + test "notifications/roots/list_changed is handled as no-op by default" do + result = @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + + assert_nil result + end + + test "init stores client capabilities" do + server = Server.new(name: "test", version: "1.0") + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_equal({ roots: { listChanged: true } }, server.client_capabilities) + end + + test "init stores nil when client sends no capabilities" do + server = Server.new(name: "test", version: "1.0") + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_nil server.client_capabilities + end + + test "list_roots uses per-session capabilities via ServerSession" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + session_with_roots = ServerSession.new(server: @server, transport: transport, session_id: "s1") + session_with_roots.store_client_info(client: { name: "capable" }, capabilities: { roots: {} }) + 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 + end + assert_equal("No active stream for roots/list request.", error_with_roots.message) + + session_without_roots = ServerSession.new(server: @server, transport: transport, session_id: "s2") + session_without_roots.store_client_info(client: { name: "incapable" }, capabilities: {}) + + error = assert_raises(RuntimeError) do + session_without_roots.list_roots + end + assert_equal("Client does not support roots.", error.message) + end + + test "ServerSession#client_capabilities falls back to server global capabilities" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + session = ServerSession.new(server: @server, transport: transport, session_id: "s3") + transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session } + + error = assert_raises(RuntimeError) do + session.list_roots + end + assert_equal("No active stream for roots/list request.", error.message) + end + + test "session init does not overwrite server global client_capabilities" do + server = Server.new(name: "test", version: "1.0") + MockTransport.new(server) + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "first-client", version: "1.0" }, + }, + }) + + assert_equal({ roots: {} }, server.client_capabilities) + + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "second-client", version: "1.0" }, + }, + }, + session: session, + ) + + assert_equal({ roots: {} }, server.client_capabilities) + assert_equal({}, session.client_capabilities) + end + + test "ServerSession#list_roots uses session-scoped capabilities from HTTP init" do + server = Server.new(name: "test", version: "1.0") + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "http-client", version: "1.0" }, + }, + }, + session: session, + ) + + transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session } + error = assert_raises(RuntimeError) do + session.list_roots + end + assert_equal("No active stream for roots/list request.", error.message) + end + end +end From acdf85f03907abc730f5c6a21179e5dd4479f0b4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 15 Apr 2026 14:30:52 +0900 Subject: [PATCH 044/247] [Doc] Restructure README Usage section to align heading granularity Group the Rails (mount) and Rails (controller) examples under a new `Streamable HTTP Transport` heading so they sit one level below the transport-type headings instead of alongside `Stdio Transport`. Also move the single-process and sticky-sessions caveat up to the `Streamable HTTP Transport` section since it applies to both Rails integration styles rather than only the controller approach. --- README.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6ecb082..3d01514 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,24 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -#### Rails (mount) +#### Streamable HTTP Transport + +`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. +The following examples show two common integration styles in Rails. + +> [!IMPORTANT] +> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, +> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). +> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. + +##### Rails (mount) `StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: @@ -125,7 +142,13 @@ Rails.application.routes.draw do end ``` -#### Rails (controller) +`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches +`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), +`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per +the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), +so no additional route configuration is needed. + +##### Rails (controller) While the mount approach creates a single server at boot time, the controller approach creates a new server per request. This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). @@ -153,18 +176,6 @@ class McpController < ActionController::API end ``` -> [!IMPORTANT] -> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> -> When running multiple server instances behind a load balancer, configure your load balancer to use -> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always -> routed to the same instance. -> -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - ### Configuration The gem can be configured using the `MCP.configure` block: From 0c5432b02730ba3b2e8158528024eae59f7b5b00 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 13 Apr 2026 21:02:28 +0900 Subject: [PATCH 045/247] [Doc] Update homepage with just-the-docs theme Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/pull/181. The landing page at https://ruby.sdk.modelcontextprotocol.io previously only listed version links to RubyDoc.info. Add multi-page documentation using the just-the-docs Jekyll theme with sidebar navigation, search, and code highlighting. Pages: Introduction, Installation, Building Servers, and Building Clients. This PR introduces a basic set of pages. Additional content can be added incrementally in future updates. --- docs/_config.yml | 17 +++ docs/building-clients.md | 116 +++++++++++++++++ docs/building-servers.md | 275 +++++++++++++++++++++++++++++++++++++++ docs/index.md | 78 ++++++++++- docs/installation.md | 32 +++++ 5 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 docs/building-clients.md create mode 100644 docs/building-servers.md create mode 100644 docs/installation.md diff --git a/docs/_config.yml b/docs/_config.yml index 3b4ca1f..d488c9e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,6 +1,23 @@ # Use package name as site title title: "MCP Ruby SDK" +description: "The official Ruby SDK for Model Context Protocol servers and clients." +remote_theme: just-the-docs/just-the-docs # Include generated files and directories which may start with underscores include: - "_*" + +# Header links +aux_links: + GitHub: https://github.com/modelcontextprotocol/ruby-sdk + +# Search +search_enabled: true + +# Footer +gh_edit_link: true +gh_edit_link_text: "Edit this page on GitHub." +gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk" +gh_edit_branch: "main" +gh_edit_source: "docs" +gh_edit_view_mode: "edit" diff --git a/docs/building-clients.md b/docs/building-clients.md new file mode 100644 index 0000000..45c7e8d --- /dev/null +++ b/docs/building-clients.md @@ -0,0 +1,116 @@ +--- +layout: default +title: Building Clients +nav_order: 4 +--- + +# Building an MCP Client + +The `MCP::Client` class provides an interface for interacting with MCP servers. + +**Supported operations:** + +- Tool listing (`MCP::Client#tools`) and invocation (`MCP::Client#call_tool`) +- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resources`) +- Resource template listing (`MCP::Client#resource_templates`) +- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`) +- Completion requests (`MCP::Client#complete`) + +## Stdio Transport + +Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +stdio_transport.close +``` + +| Parameter | Required | Description | +|---|---|---| +| `command:` | Yes | The command to spawn the server process. | +| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | +| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. | +| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. | + +## HTTP Transport + +Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem: + +```ruby +gem 'mcp' +gem 'faraday', '>= 2.0' +``` + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") +client = MCP::Client.new(transport: http_transport) + +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) +``` + +### Authorization + +Provide custom headers for authentication: + +```ruby +http_transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) +client = MCP::Client.new(transport: http_transport) +``` + +### Customizing the Faraday Connection + +Pass a block to customize the underlying Faraday connection: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| + faraday.use MyApp::Middleware::HttpRecorder + faraday.adapter :typhoeus +end +``` + +## Custom Transport + +If the built-in transports do not fit your needs, you can implement your own: + +```ruby +class CustomTransport + def send_request(request:) + # Your transport-specific logic here. + # Returns a Hash modeling a JSON-RPC response object. + end +end + +client = MCP::Client.new(transport: CustomTransport.new) +``` + +For more details, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-client). diff --git a/docs/building-servers.md b/docs/building-servers.md new file mode 100644 index 0000000..0fd8d70 --- /dev/null +++ b/docs/building-servers.md @@ -0,0 +1,275 @@ +--- +layout: default +title: Building Servers +nav_order: 3 +--- + +# Building an MCP Server + +The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification. + +## Supported Methods + +- `initialize` - Initializes the protocol and returns server capabilities +- `ping` - Simple health check +- `tools/list` - Lists all registered tools and their schemas +- `tools/call` - Invokes a specific tool with provided arguments +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves a specific prompt by name +- `resources/list` - Lists all registered resources and their schemas +- `resources/read` - Retrieves a specific resource by name +- `resources/templates/list` - Lists all registered resource templates and their schemas +- `resources/subscribe` - Subscribes to updates for a specific resource +- `resources/unsubscribe` - Unsubscribes from updates for a specific resource +- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) + +## Stdio Transport + +If you want to build a local command-line application, you can use the stdio transport: + +```ruby +require "mcp" + +class ExampleTool < MCP::Tool + description "A simple example tool that echoes back its arguments" + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + + class << self + def call(message:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +server = MCP::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open +``` + +## Streamable HTTP Transport + +`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. +The following examples show two common integration styles in Rails. + +{: .important } +> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, +> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). +> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. + +### Rails (mount) + +`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: + +```ruby +# config/routes.rb +server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], +) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches +`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), +`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per +the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), +so no additional route configuration is needed. + +### Rails (controller) + +While the mount approach creates a single server at boot time, the controller approach creates a new server per request. +This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). + +`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): + +```ruby +class McpController < ActionController::API + def create + server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, + ) + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + status, headers, body = transport.handle_request(request) + + render(json: body.first, status: status, headers: headers) + end +end +``` + +## Tools + +Tools provide functionality to LLM applications. There are three ways to define tools: + +### Class Definition + +```ruby +class MyTool < MCP::Tool + title "My Tool" + description "This tool performs specific functionality..." + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + annotations( + read_only_hint: true, + destructive_hint: false, + ) + + def self.call(message:, server_context:) + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) + end +end +``` + +### Block Definition + +```ruby +tool = MCP::Tool.define( + name: "my_tool", + description: "This tool performs specific functionality...", +) do |args, server_context:| + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +### Server-level Definition + +```ruby +server = MCP::Server.new +server.define_tool( + name: "my_tool", + description: "This tool performs specific functionality...", +) do |args, server_context:| + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +## Prompts + +Prompts are templates for LLM interactions. Like tools, they can be defined in three ways: + +### Class Definition + +```ruby +class CodeReviewPrompt < MCP::Prompt + prompt_name "code_review" + description "Review code for best practices" + arguments [ + MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + description: "Code review", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") + ), + ] + ) + end + end +end +``` + +### Server-level Definition + +```ruby +server.define_prompt( + name: "code_review", + description: "Review code for best practices", + arguments: [ + MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), + ] +) do |args, server_context:| + MCP::Prompt::Result.new( + description: "Code review", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") + ), + ] + ) +end +``` + +## Resources + +Resources provide data access to LLM applications: + +```ruby +class MyResource < MCP::Resource + uri "file:///data/config.json" + resource_name "config" + description "Application configuration" + mime_type "application/json" +end + +server = MCP::Server.new( + name: "my_server", + resources: [MyResource], + resources_read_handler: ->(uri, _server_context) { + case uri + when "file:///data/config.json" + { uri: uri, text: File.read("config.json"), mimeType: "application/json" } + end + } +) +``` + +## Configuration + +```ruby +MCP.configure do |config| + config.exception_reporter = ->(exception, server_context) { + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, server_context) + end + } + + config.instrumentation_callback = ->(data) { + puts "Got instrumentation data #{data.inspect}" + } +end +``` + +For more details on sampling, notifications, progress tracking, completions, logging, and advanced features, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-server). diff --git a/docs/index.md b/docs/index.md index 305c3a1..564b5f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,77 @@ --- -# Empty Jekyll front matter to enable Liquid templating (see {{ ... }} below) +layout: default +title: Introduction +nav_order: 1 --- -{% for version in site.data.versions -%} -- [v{{ version }}](https://rubydoc.info/gems/mcp/{{ version }}) -{% endfor %} +The official Ruby SDK for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), implementing both server and client functionality for JSON-RPC 2.0 based communication between LLM applications and context providers. + +**Key features:** + +- JSON-RPC 2.0 message handling with protocol initialization and capability negotiation +- Tool, prompt, and resource registration and invocation +- Stdio and Streamable HTTP (including SSE) transports +- Client support for communicating with MCP servers +- Notifications, sampling, progress tracking, and completions + +## Quick Start + +Here is a minimal MCP server using the stdio transport: + +```ruby +require "mcp" + +class ExampleTool < MCP::Tool + description "A simple example tool that echoes back its arguments" + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + + class << self + def call(message:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +server = MCP::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open +``` + +Run the script and send JSON-RPC requests via stdin: + +```console +$ ruby server.rb +{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"example","version":"0.1.0"}}} +{"jsonrpc":"2.0","id":"2","method":"tools/list"} +{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} +``` + +For comprehensive documentation, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md). + +## API Documentation + +Full API reference is hosted on [RubyDoc.info](https://rubydoc.info/gems/mcp). Select a version to view: + + + +## License + +This project is transitioning from the MIT License to the Apache License 2.0. See [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) for details. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..0822994 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,32 @@ +--- +layout: default +title: Installation +nav_order: 2 +--- + +# Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'mcp' +``` + +And then execute: + +```console +$ bundle install +``` + +Or install it yourself as: + +```console +$ gem install mcp +``` + +You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: + +```ruby +gem 'mcp' +gem 'faraday', '>= 2.0' +``` From 954078d90c55f4963ba3d9d29742fa7fd3cc907d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 16 Apr 2026 02:14:52 +0900 Subject: [PATCH 046/247] Release 0.13.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. In particular, releasing "Make `StreamableHTTPTransport` a Rack application" soon would help keep it consistent with the README.md for Rails integration. --- CHANGELOG.md | 13 +++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc50870..9e0e96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-04-16 + +### Added + +- Make `StreamableHTTPTransport` a Rack application (#263) +- Support `elicitation/create` per MCP specification (#312) +- Add `around_request` hook for request instrumentation (#309) +- Add `_meta` field to resource, content, and result classes (#310) + +### Removed + +- Remove `Server#create_sampling_message` direct call (#311) + ## [0.12.0] - 2026-04-11 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index aeddea7..8378006 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.12.0" + VERSION = "0.13.0" end From 8ccdb5a540ee7cd1331685f2be398452645becda Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 17 Apr 2026 00:33:54 +0900 Subject: [PATCH 047/247] Update homepage URL in gemspec Follow-up to #317. This PR updates the homepage URL to https://ruby.sdk.modelcontextprotocol.io and makes it consistent with the link on the SDK page: https://modelcontextprotocol.io/docs/sdk --- mcp.gemspec | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp.gemspec b/mcp.gemspec index 2d8de1c..4c44f49 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.summary = "The official Ruby SDK for Model Context Protocol servers and clients" spec.description = spec.summary - spec.homepage = "https://github.com/modelcontextprotocol/ruby-sdk" + spec.homepage = "https://ruby.sdk.modelcontextprotocol.io" spec.license = "Apache-2.0" # Since this library is used by a broad range of users, it does not align its support policy with Ruby's EOL. @@ -19,8 +19,8 @@ Gem::Specification.new do |spec| spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["changelog_uri"] = "https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v#{spec.version}" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" + spec.metadata["source_code_uri"] = "https://github.com/modelcontextprotocol/ruby-sdk" + spec.metadata["bug_tracker_uri"] = "#{spec.metadata["source_code_uri"]}/issues" spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/mcp" spec.files = Dir["lib/**/*"] From b97b922a36fc8b0cd15c2eb39aba969a6aa648a3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 17 Apr 2026 02:24:03 +0900 Subject: [PATCH 048/247] Support pagination per MCP specification ## Motivation and Context The MCP specification defines cursor-based pagination for list operations that may return large result sets: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination Pagination allows servers to yield results in smaller chunks rather than all at once, which is especially important when connecting to external services over the internet. The Ruby SDK previously returned complete arrays for all list endpoints (`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`) without pagination support. This adds cursor-based pagination. ### Server-side A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`) that provides the `paginate` helper for slicing a collection by cursor and a `cursor_from` helper that extracts the cursor from the request params while rejecting non-Hash inputs with `-32602 Invalid params` per the specification. `MCP::Server.new` accepts a new `page_size:` keyword argument. When `nil` (the default), all items are returned in a single response, preserving the existing behavior. When set to a positive integer, list responses include a `nextCursor` field if more pages are available. `Server#page_size=` is a validating setter that rejects anything other than `nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs. The constructor routes through the setter, so both `Server.new(page_size: 0)` and `server.page_size = -1` raise. Cursors are string tokens carrying a zero-based offset, treated as opaque by clients. Cursor inputs are validated to be strings per the MCP spec; invalid cursors (non-string, non-numeric, negative, or out-of-range) raise `RequestHandlerError` with error code `-32602 (Invalid params)` per the spec. ### Client-side: two complementary APIs The SDK exposes two API shapes for listing, each serving a different use case: 1. **Single-page with cursor** (`client.list_tools(cursor:)`, `list_resources`, `list_resource_templates`, `list_prompts`): each call issues one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.) carrying the page items and an optional `next_cursor`. These methods match the single-page-with-cursor convention used by the Python SDK (`session.list_tools(params=PaginatedRequestParams(cursor=...))`) and the TypeScript SDK (`client.listTools({ cursor })`). The method name and the `cursor` parameter name are identical across the three SDKs, so pagination code translates directly between languages. 2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`, `client.prompts`): auto-iterates through all pages and returns a plain array of items, guaranteeing the full collection regardless of the server's `page_size` setting. The auto-pagination loop tracks previously seen cursors in a set and exits if the server revisits any of them, guarding against both immediate repeats and multi-cursor cycles (e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods, though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility. Future rename to `list_all_*` is left as a TODO in the source. Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` / `ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes its page items (e.g. `result.tools`), an optional `next_cursor` for continuation, and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field so servers that decorate list responses do not lose metadata through the client. ### Ruby-specific ergonomics `Server.new(page_size:)` and the `Pagination` module are Ruby-specific. The Python and TypeScript SDKs do not expose a built-in `page_size` helper; developers there re-implement cursor slicing inside each handler. In Ruby, setting `page_size:` on the server is enough for the SDK to handle the slicing across the four built-in list endpoints. ### README A Pagination section documents both the server-side `page_size:` option and the client-side iteration patterns, including an explicit note that each `list_*` call is a single JSON-RPC round trip whose response size depends on the server's `page_size`, and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return the complete collection when needed. ## How Has This Been Tested? - Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`), server-side pagination (`test/mcp/server_test.rb`), and client-side pagination (`test/mcp/client_test.rb`). - Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes. ## Breaking Changes None. This is a new feature addition. Existing code continues to work unchanged: `page_size` defaults to `nil` on the server (returns all items, no `nextCursor`), and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods continue to return complete arrays by transparently iterating through pages. --- README.md | 85 ++++++++ lib/mcp/client.rb | 184 +++++++++++++++-- lib/mcp/client/paginated_result.rb | 13 ++ lib/mcp/server.rb | 38 ++-- lib/mcp/server/pagination.rb | 42 ++++ test/mcp/client_test.rb | 316 +++++++++++++++++++++++++++++ test/mcp/server/pagination_test.rb | 166 +++++++++++++++ test/mcp/server_test.rb | 161 +++++++++++++++ 8 files changed, 970 insertions(+), 35 deletions(-) create mode 100644 lib/mcp/client/paginated_result.rb create mode 100644 lib/mcp/server/pagination.rb create mode 100644 test/mcp/server/pagination_test.rb diff --git a/README.md b/README.md index c96b04c..866c213 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports notifications for list changes (tools, prompts, resources) - Supports roots (server-to-client filesystem boundary queries) - Supports sampling (server-to-client LLM completion requests) +- Supports cursor-based pagination for list operations ### Supported Methods @@ -1365,6 +1366,90 @@ When configured, sessions that receive no HTTP requests for this duration are au transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) ``` +### Pagination + +The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) +for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, +treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`. + +#### Server-Side: Enabling Pagination + +Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), +list responses contain all items in a single response, preserving the pre-pagination behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + page_size: 50, +) +``` + +When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { "name": "example_tool" } + ], + "nextCursor": "50" + } +} +``` + +Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. + +#### Client-Side: Iterating Pages + +`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. +**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection. +The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors: + +```ruby +client = MCP::Client.new(transport: transport) + +cursor = nil +loop do + page = client.list_tools(cursor: cursor) + page.tools.each { |tool| process(tool) } + cursor = page.next_cursor + break unless cursor +end +``` + +The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and +`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page. + +Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration: + +| Server `page_size` | `client.list_tools(cursor: nil)` | +|--------------------|---------------------------------------------------------------------| +| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. | +| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. | + +If your application needs the complete collection regardless of how the server is configured, either loop on +`next_cursor` as shown above, or use the whole-collection methods described below. + +#### Fetching the Complete Collection + +`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate +through all pages and return a plain array of items, guaranteeing the full collection regardless +of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round +trips per call and break out of the pagination loop if the server returns the same `nextCursor` +twice in a row as a safety measure. + +```ruby +tools = client.tools # => Array of every tool on the server. +``` + +Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need +fine-grained iteration (e.g. to stream-process pages without loading everything into memory). + ### Advanced #### Custom Methods diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index d37b24a..e4cfd02 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -2,6 +2,7 @@ require_relative "client/stdio" require_relative "client/http" +require_relative "client/paginated_result" require_relative "client/tool" module MCP @@ -43,8 +44,41 @@ module MCP # So keeping it public attr_reader :transport - # Returns the list of tools available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of tools from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListToolsResult] Result with `tools` (Array) + # and `next_cursor` (String or nil). + # + # @example Iterate all pages + # cursor = nil + # loop do + # page = client.list_tools(cursor: cursor) + # page.tools.each { |tool| puts tool.name } + # cursor = page.next_cursor + # break unless cursor + # end + def list_tools(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "tools/list", params: params) + result = response["result"] || {} + + tools = (result["tools"] || []).map do |tool| + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"], + ) + end + + ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"]) + end + + # Returns every tool available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_tools} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available tools. # @@ -54,45 +88,151 @@ module MCP # puts tool.name # end def tools - response = request(method: "tools/list") + # TODO: consider renaming to `list_all_tools`. + all_tools = [] + seen = Set.new + cursor = nil - response.dig("result", "tools")&.map do |tool| - Tool.new( - name: tool["name"], - description: tool["description"], - input_schema: tool["inputSchema"], - ) - end || [] + loop do + page = list_tools(cursor: cursor) + all_tools.concat(page.tools) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_tools end - # Returns the list of resources available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of resources from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array) + # and `next_cursor` (String or nil). + def list_resources(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/list", params: params) + result = response["result"] || {} + + ListResourcesResult.new( + resources: result["resources"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) + end + + # Returns every resource available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resources} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available resources. def resources - response = request(method: "resources/list") + # TODO: consider renaming to `list_all_resources`. + all_resources = [] + seen = Set.new + cursor = nil - response.dig("result", "resources") || [] + loop do + page = list_resources(cursor: cursor) + all_resources.concat(page.resources) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_resources end - # Returns the list of resource templates available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of resource templates from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates` + # (Array) and `next_cursor` (String or nil). + def list_resource_templates(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/templates/list", params: params) + result = response["result"] || {} + + ListResourceTemplatesResult.new( + resource_templates: result["resourceTemplates"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) + end + + # Returns every resource template available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resource_templates} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available resource templates. def resource_templates - response = request(method: "resources/templates/list") + # TODO: consider renaming to `list_all_resource_templates`. + all_templates = [] + seen = Set.new + cursor = nil - response.dig("result", "resourceTemplates") || [] + loop do + page = list_resource_templates(cursor: cursor) + all_templates.concat(page.resource_templates) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_templates end - # Returns the list of prompts available from the server. - # Each call will make a new request – the result is not cached. + # Returns a single page of prompts from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array) + # and `next_cursor` (String or nil). + def list_prompts(cursor: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "prompts/list", params: params) + result = response["result"] || {} + + ListPromptsResult.new( + prompts: result["prompts"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ) + end + + # Returns every prompt available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_prompts} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. # # @return [Array] An array of available prompts. def prompts - response = request(method: "prompts/list") + # TODO: consider renaming to `list_all_prompts`. + all_prompts = [] + seen = Set.new + cursor = nil - response.dig("result", "prompts") || [] + loop do + page = list_prompts(cursor: cursor) + all_prompts.concat(page.prompts) + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + all_prompts end # Calls a tool via the transport layer and returns the full response from the server. diff --git a/lib/mcp/client/paginated_result.rb b/lib/mcp/client/paginated_result.rb new file mode 100644 index 0000000..18bd26b --- /dev/null +++ b/lib/mcp/client/paginated_result.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MCP + class Client + # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. + # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination, + # and an optional `meta` hash mirroring the MCP `_meta` response field. + ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true) + ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true) + ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true) + ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true) + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e1fd2c4..241002c 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -6,6 +6,7 @@ require_relative "methods" require_relative "logging_message_notification" require_relative "progress" require_relative "server_context" +require_relative "server/pagination" require_relative "server/transports" module MCP @@ -65,9 +66,10 @@ module MCP end include Instrumentation + include Pagination attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :client_capabilities + attr_reader :page_size, :client_capabilities def initialize( description: nil, @@ -84,6 +86,7 @@ module MCP server_context: nil, configuration: nil, capabilities: nil, + page_size: nil, transport: nil ) @description = description @@ -100,6 +103,7 @@ module MCP @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @server_context = server_context + self.page_size = page_size @configuration = MCP.configuration.merge(configuration) @client = nil @@ -183,6 +187,14 @@ module MCP @handlers[method_name] = block end + def page_size=(page_size) + unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0) + raise ArgumentError, "page_size must be nil or a positive integer" + end + + @page_size = page_size + end + def notify_tools_list_changed return unless @transport @@ -377,16 +389,8 @@ module MCP result = case method when Methods::INITIALIZE init(params, session: session) - when Methods::TOOLS_LIST - { tools: @handlers[Methods::TOOLS_LIST].call(params) } - when Methods::PROMPTS_LIST - { prompts: @handlers[Methods::PROMPTS_LIST].call(params) } - when Methods::RESOURCES_LIST - { resources: @handlers[Methods::RESOURCES_LIST].call(params) } when Methods::RESOURCES_READ { contents: @handlers[Methods::RESOURCES_READ].call(params) } - when Methods::RESOURCES_TEMPLATES_LIST - { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } when Methods::TOOLS_CALL call_tool(params, session: session, related_request_id: related_request_id) when Methods::COMPLETION_COMPLETE @@ -488,7 +492,9 @@ module MCP end def list_tools(request) - @tools.values.map(&:to_h) + page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { tools: page[:items], nextCursor: page[:next_cursor] }.compact end def call_tool(request, session: nil, related_request_id: nil) @@ -536,7 +542,9 @@ module MCP end def list_prompts(request) - @prompts.values.map(&:to_h) + page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { prompts: page[:items], nextCursor: page[:next_cursor] }.compact end def get_prompt(request) @@ -556,7 +564,9 @@ module MCP end def list_resources(request) - @resources.map(&:to_h) + page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { resources: page[:items], nextCursor: page[:next_cursor] }.compact end # Server implementation should set `resources_read_handler` to override no-op default @@ -566,7 +576,9 @@ module MCP end def list_resource_templates(request) - @resource_templates.map(&:to_h) + page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact end def complete(params) diff --git a/lib/mcp/server/pagination.rb b/lib/mcp/server/pagination.rb new file mode 100644 index 0000000..aa26b21 --- /dev/null +++ b/lib/mcp/server/pagination.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module MCP + class Server + module Pagination + private + + def cursor_from(request) + return if request.nil? + + unless request.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params) + end + + request[:cursor] + end + + def paginate(items, cursor:, page_size:, request:, &block) + start_index = 0 + + if cursor + unless cursor.is_a?(String) + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + + start_index = Integer(cursor, exception: false) + if start_index.nil? || start_index < 0 || start_index >= items.size + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + end + + end_index = page_size ? start_index + page_size : items.size + page = items[start_index...end_index] + page = page.map(&block) if block + + result = { items: page } + result[:next_cursor] = end_index.to_s if end_index < items.size + result + end + end + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 0252e0e..5414469 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -537,5 +537,321 @@ module MCP assert_equal([], result["values"]) refute(result["hasMore"]) end + + def test_tools_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + call_count = 0 + transport.expects(:send_request).twice.with do |args| + call_count += 1 + req = args[:request] + if call_count == 1 + req[:method] == "tools/list" && req[:params].nil? + else + req[:method] == "tools/list" && req[:params] == { cursor: "cursor1" } + end + end.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + assert_equal("tool1", tools[0].name) + assert_equal("tool2", tools[1].name) + end + + def test_list_tools_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools + + assert_equal(1, result.tools.size) + assert_equal("tool1", result.tools[0].name) + assert_equal("cursor1", result.next_cursor) + end + + def test_list_tools_with_cursor_param + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params) == { cursor: "cursor1" } + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools(cursor: "cursor1") + + assert_equal(1, result.tools.size) + assert_equal("tool2", result.tools[0].name) + assert_nil(result.next_cursor) + end + + def test_resources_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resources" => [{ "uri" => "https://b.invalid", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + assert_equal("a", resources[0]["name"]) + assert_equal("b", resources[1]["name"]) + end + + def test_list_resources_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resources + + assert_equal(1, result.resources.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_resource_templates_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://b.invalid/{id}", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + assert_equal("a", templates[0]["name"]) + assert_equal("b", templates[1]["name"]) + end + + def test_list_resource_templates_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resource_templates + + assert_equal(1, result.resource_templates.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_prompts_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "prompts" => [{ "name" => "prompt_b", "description" => "B" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + assert_equal("prompt_a", prompts[0]["name"]) + assert_equal("prompt_b", prompts[1]["name"]) + end + + def test_tools_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "stuck_cursor", + }, + } + + # If the server keeps returning the same cursor, the client must not loop forever. + # Expect at most 2 calls: the initial request (cursor=nil) and one retry (cursor="stuck_cursor") + # that detects the repeat and breaks out. + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + end + + def test_tools_breaks_when_server_cycles_between_cursors + transport = mock + + page_a = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + page_b = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + "nextCursor" => "B", + }, + } + # Server cycles A -> B -> A. Client must detect the revisited cursor and break. + page_a_again = { + "result" => { + "tools" => [{ "name" => "tool3", "description" => "tool3", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + + transport.expects(:send_request).times(3).returns(page_a, page_b, page_a_again) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(3, tools.size) + assert_equal(["tool1", "tool2", "tool3"], tools.map(&:name)) + end + + def test_resources_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + end + + def test_resource_templates_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + end + + def test_prompts_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + end + + def test_list_prompts_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_prompts + + assert_equal(1, result.prompts.size) + assert_equal("cursor1", result.next_cursor) + end end end diff --git a/test/mcp/server/pagination_test.rb b/test/mcp/server/pagination_test.rb new file mode 100644 index 0000000..4885459 --- /dev/null +++ b/test/mcp/server/pagination_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class PaginationTest < ActiveSupport::TestCase + class PaginationHost + include Pagination + + # Expose private methods for testing. + public :paginate, :cursor_from + + # RequestHandlerError must be accessible. + RequestHandlerError = Server::RequestHandlerError + end + + setup do + @host = PaginationHost.new + @items = [{ name: "a" }, { name: "b" }, { name: "c" }, { name: "d" }, { name: "e" }] + @request = { method: "tools/list" } + end + + test "paginate returns all items when page_size is nil and no cursor" do + result = @host.paginate(@items, cursor: nil, page_size: nil, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate returns first page with nextCursor when page_size is set" do + result = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + + assert_equal [{ name: "a" }, { name: "b" }], result[:items] + assert_not_nil result[:next_cursor] + end + + test "paginate returns correct second page when cursor is provided" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }], second[:items] + assert_not_nil second[:next_cursor] + end + + test "paginate returns last page without nextCursor" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + third = @host.paginate(@items, cursor: second[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "e" }], third[:items] + assert_nil third[:next_cursor] + end + + test "paginate with page_size equal to items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 5, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with page_size larger than items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 100, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with empty items returns empty array" do + result = @host.paginate([], cursor: nil, page_size: 2, request: @request) + + assert_equal [], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError for non-numeric cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "not_a_number", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Integer cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 2, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Float cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 1.5, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for negative offset cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "-1", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for out-of-range cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "100", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate returns all items from cursor offset when page_size is nil" do + result = @host.paginate(@items, cursor: "2", page_size: nil, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }, { name: "e" }], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError when cursor points exactly at items.size" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: @items.size.to_s, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate on empty items with any cursor raises RequestHandlerError" do + error = assert_raises(RequestHandlerError) do + @host.paginate([], cursor: "0", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "cursor_from returns nil for nil request" do + assert_nil @host.cursor_from(nil) + end + + test "cursor_from returns cursor for Hash request" do + assert_equal "abc", @host.cursor_from(cursor: "abc") + end + + test "cursor_from returns nil when Hash request has no cursor" do + assert_nil @host.cursor_from({}) + end + + test "cursor_from raises RequestHandlerError for non-Hash request" do + error = assert_raises(RequestHandlerError) do + @host.cursor_from([1, 2, 3]) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate with page_size 1 iterates one item at a time" do + results = [] + cursor = nil + + loop do + page = @host.paginate(@items, cursor: cursor, page_size: 1, request: @request) + results.concat(page[:items]) + cursor = page[:next_cursor] + break unless cursor + end + + assert_equal @items, results + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 14b9182..73b924b 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2331,5 +2331,166 @@ module MCP server.handle(request) assert_equal "from_accessor", received_context[:custom] end + + test "#handle tools/list returns paginated results when page_size is set" do + tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") + tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") + tool_c = Tool.define(name: "tool_c", title: "Tool C", description: "Tool C") + + server = Server.new( + name: "pagination_test", + tools: [tool_a, tool_b, tool_c], + page_size: 2, + ) + + first_request = { jsonrpc: "2.0", method: "tools/list", id: 1 } + first_response = server.handle(first_request) + first_result = first_response[:result] + + assert_equal 2, first_result[:tools].size + assert_equal "tool_a", first_result[:tools][0][:name] + assert_equal "tool_b", first_result[:tools][1][:name] + assert_not_nil first_result[:nextCursor] + + second_request = { jsonrpc: "2.0", method: "tools/list", id: 2, params: { cursor: first_result[:nextCursor] } } + second_response = server.handle(second_request) + second_result = second_response[:result] + + assert_equal 1, second_result[:tools].size + assert_equal "tool_c", second_result[:tools][0][:name] + # Final page omits the nextCursor key entirely (not just sets it to nil). + refute second_result.key?(:nextCursor) + end + + test "#handle tools/list returns all tools when page_size is not set" do + response = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) + result = response[:result] + + assert_kind_of Array, result[:tools] + assert_nil result[:nextCursor] + end + + test "#handle tools/list returns error for invalid cursor" do + server = Server.new(name: "pagination_test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: { cursor: "!!!invalid!!!" } } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle prompts/list returns paginated results when page_size is set" do + prompt_a = Prompt.define(name: "prompt_a", title: "Prompt A", description: "A") { Prompt::Result.new(description: "A", messages: []) } + prompt_b = Prompt.define(name: "prompt_b", title: "Prompt B", description: "B") { Prompt::Result.new(description: "B", messages: []) } + + server = Server.new(name: "pagination_test", prompts: [prompt_a, prompt_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:prompts].size + assert_equal "prompt_a", first_result[:prompts][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:prompts].size + assert_equal "prompt_b", second_result[:prompts][0][:name] + assert_nil second_result[:nextCursor] + end + + test "#handle resources/list returns paginated results when page_size is set" do + resource_a = Resource.new(uri: "https://a.invalid", name: "a", description: "A", mime_type: "text/plain") + resource_b = Resource.new(uri: "https://b.invalid", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resources: [resource_a, resource_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resources].size + assert_equal "a", first_result[:resources][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resources].size + assert_equal "b", second_result[:resources][0][:name] + assert_nil second_result[:nextCursor] + end + + test "Server.new raises ArgumentError when page_size is zero" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: 0) + end + end + + test "Server.new raises ArgumentError when page_size is negative" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: -1) + end + end + + test "Server.new raises ArgumentError when page_size is non-Integer" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: "10") + end + end + + test "page_size= raises ArgumentError for invalid values" do + server = Server.new(name: "test") + + assert_raises(ArgumentError) { server.page_size = 0 } + assert_raises(ArgumentError) { server.page_size = -1 } + assert_raises(ArgumentError) { server.page_size = "5" } + + server.page_size = nil + server.page_size = 10 + assert_equal 10, server.page_size + end + + test "#handle tools/list returns -32602 for non-Hash params" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: [1, 2, 3] } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle_json tools/list returns -32602 for numeric cursor (spec requires string)" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request_json = '{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{"cursor":0}}' + response = JSON.parse(server.handle_json(request_json), symbolize_names: true) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle resources/templates/list returns paginated results when page_size is set" do + template_a = ResourceTemplate.new(uri_template: "https://a.invalid/{id}", name: "a", description: "A", mime_type: "text/plain") + template_b = ResourceTemplate.new(uri_template: "https://b.invalid/{id}", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resource_templates: [template_a, template_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resourceTemplates].size + assert_equal "a", first_result[:resourceTemplates][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resourceTemplates].size + assert_equal "b", second_result[:resourceTemplates][0][:name] + assert_nil second_result[:nextCursor] + end end end From d80fba4fbce79ef2323fd9094247f5fdeae3feef Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 14 Apr 2026 00:56:54 +0900 Subject: [PATCH 049/247] Support resource subscriptions per MCP specification ## Motivation and Context The MCP specification defines `resources/subscribe`, `resources/unsubscribe`, and `notifications/resources/updated` for clients to monitor resource changes: https://modelcontextprotocol.io/specification/2025-03-26/server/resources#subscriptions The Ruby SDK had stub (no-op) handlers but provided no way for server developers to customize subscription behavior or send update notifications. Following the Python SDK approach, the SDK does not track subscription state internally. Server developers register handler blocks and manage their own subscription state, allowing flexibility for different subscription semantics (per-session tracking, persistence, debouncing, etc.). Three methods are added: - `Server#resources_subscribe_handler`: registers a handler for `resources/subscribe` requests - `Server#resources_unsubscribe_handler`: registers a handler for `resources/unsubscribe` requests - `ServerSession#notify_resources_updated`: sends a `notifications/resources/updated` notification to the subscribing client `ServerContext#notify_resources_updated` is also added so that tool handlers can send the notification scoped to the originating session. ## How Has This Been Tested? All tests pass (`rake test`), RuboCop is clean. New tests cover custom handler registration for `resources/subscribe` and `resources/unsubscribe`, session-scoped `notify_resources_updated` notifications, error handling, and `ServerContext` delegation. ## Breaking Changes None. --- README.md | 44 ++++++++++++++++++++-- conformance/server.rb | 14 +++++++ lib/mcp/server.rb | 27 ++++++++++++-- lib/mcp/server_context.rb | 9 +++++ lib/mcp/server_session.rb | 7 ++++ test/mcp/server_context_test.rb | 17 +++++++++ test/mcp/server_test.rb | 66 +++++++++++++++++++++++++++++++++ 7 files changed, 176 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 866c213..202b401 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ It implements the Model Context Protocol specification, handling model context r - `resources/list` - Lists all registered resources and their schemas - `resources/read` - Retrieves a specific resource by name - `resources/templates/list` - Lists all registered resource templates and their schemas +- `resources/subscribe` - Subscribes to updates for a specific resource +- `resources/unsubscribe` - Unsubscribes from updates for a specific resource - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs - `roots/list` - Requests filesystem roots from the client (server-to-client) - `sampling/createMessage` - Requests LLM completion from the client (server-to-client) @@ -958,6 +960,44 @@ end - Raises `RuntimeError` if client does not support `roots` capability - Raises `StandardError` if client returns an error response +### Resource Subscriptions + +Resource subscriptions allow clients to monitor specific resources for changes. +When a subscribed resource is updated, the server sends a notification to the client. + +The SDK does not track subscription state internally. +Server developers register handlers and manage their own subscription state. +Three methods are provided: + +- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests +- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests +- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client + +```ruby +subscribed_uris = Set.new + +server = MCP::Server.new( + name: "my_server", + resources: [my_resource], + capabilities: { resources: { subscribe: true } }, +) + +server.resources_subscribe_handler do |params| + subscribed_uris.add(params[:uri].to_s) +end + +server.resources_unsubscribe_handler do |params| + subscribed_uris.delete(params[:uri].to_s) +end + +server.define_tool(name: "update_resource") do |server_context:, **args| + if subscribed_uris.include?("test://my-resource") + server_context.notify_resources_updated(uri: "test://my-resource") + end + MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h]) +end +``` + ### Sampling The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. @@ -1503,10 +1543,6 @@ end - Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method - Supports the same exception reporting and instrumentation as standard methods -### Unsupported Features (to be implemented in future versions) - -- Resource subscriptions - ## Building an MCP Client The `MCP::Client` class provides an interface for interacting with MCP servers. diff --git a/conformance/server.rb b/conformance/server.rb index e6ea6bd..7b697d8 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -2,6 +2,7 @@ require "rackup" require "json" +require "set" require "uri" require_relative "../lib/mcp" @@ -539,6 +540,7 @@ module Conformance server.server_context = server configure_resources_read_handler(server) + configure_subscription_handlers(server) configure_completion_handler(server) end @@ -609,6 +611,18 @@ module Conformance end end + def configure_subscription_handlers(server) + subscribed_uris = Set.new + + server.resources_subscribe_handler do |params| + subscribed_uris.add(params[:uri].to_s) + end + + server.resources_unsubscribe_handler do |params| + subscribed_uris.delete(params[:uri].to_s) + end + end + def build_rack_app(transport) mcp_app = proc do |env| request = Rack::Request.new(env) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 241002c..b687fc5 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -117,6 +117,8 @@ module MCP Methods::RESOURCES_LIST => method(:list_resources), Methods::RESOURCES_READ => method(:read_resource_no_content), Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), + Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, + Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, Methods::TOOLS_LIST => method(:list_tools), Methods::TOOLS_CALL => method(:call_tool), Methods::PROMPTS_LIST => method(:list_prompts), @@ -128,10 +130,6 @@ module MCP Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {}, Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT }, Methods::LOGGING_SET_LEVEL => method(:configure_logging_level), - - # No op handlers for currently unsupported methods - Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, - Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, } @transport = transport end @@ -258,6 +256,24 @@ module MCP @handlers[Methods::COMPLETION_COMPLETE] = block end + # Sets a custom handler for `resources/subscribe` requests. + # The block receives the parsed request params. The return value is + # ignored; the response is always an empty result `{}` per the MCP specification. + # + # @yield [params] The request params containing `:uri`. + def resources_subscribe_handler(&block) + @handlers[Methods::RESOURCES_SUBSCRIBE] = block + end + + # Sets a custom handler for `resources/unsubscribe` requests. + # The block receives the parsed request params. The return value is + # ignored; the response is always an empty result `{}` per the MCP specification. + # + # @yield [params] The request params containing `:uri`. + def resources_unsubscribe_handler(&block) + @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block + end + def build_sampling_params( capabilities, messages:, @@ -391,6 +407,9 @@ module MCP init(params, session: session) when Methods::RESOURCES_READ { contents: @handlers[Methods::RESOURCES_READ].call(params) } + when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE + @handlers[method].call(params) + {} when Methods::TOOLS_CALL call_tool(params, session: session, related_request_id: related_request_id) when Methods::COMPLETION_COMPLETE diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index f572307..7c7b87b 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -30,6 +30,15 @@ module MCP @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end + # Sends a resource updated notification scoped to the originating session. + # + # @param uri [String] The URI of the updated resource. + def notify_resources_updated(uri:) + return unless @notification_target + + @notification_target.notify_resources_updated(uri: uri) + end + # Delegates to the session so the request is scoped to the originating client. def list_roots if @notification_target.respond_to?(:list_roots) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 7d8c993..de5c2d0 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -85,6 +85,13 @@ module MCP @server.report_exception(e, notification: "elicitation_complete") end + # Sends a resource updated notification to this session only. + def notify_resources_updated(uri:) + send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri }) + rescue => e + @server.report_exception(e, notification: "resources_updated") + end + # Sends a progress notification to this session only. def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil) params = { diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index b687434..623ed85 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -192,6 +192,23 @@ module MCP assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } end + test "ServerContext#notify_resources_updated delegates to notification_target" do + notification_target = mock + notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) + + server_context.notify_resources_updated(uri: "test://resource-1") + end + + test "ServerContext#notify_resources_updated is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil) + + assert_nothing_raised { server_context.notify_resources_updated(uri: "test://resource-1") } + end + # Tool without server_context parameter class SimpleToolWithoutContext < Tool tool_name "simple_without_context" diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 73b924b..5d9186a 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2233,6 +2233,72 @@ module MCP ) end + test "#handle resources/subscribe with custom handler calls the handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + received_params = nil + server.resources_subscribe_handler do |params| + received_params = params + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + assert_equal "https://example.com/resource", received_params[:uri] + end + + test "#handle resources/unsubscribe with custom handler calls the handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + received_params = nil + server.resources_unsubscribe_handler do |params| + received_params = params + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/unsubscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + assert_equal "https://example.com/resource", received_params[:uri] + end + test "tools/call with no args" do server = Server.new(tools: [@tool_with_no_args]) From 493321ca46151aa697d7d4abbf50c8275c7d0338 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 19 Apr 2026 18:37:56 +0900 Subject: [PATCH 050/247] Support `ping` client API per MCP specification ## Motivation and Context The MCP specification defines the `ping` utility for verifying that the peer of a connection is still responsive. The Ruby SDK server side already replies to incoming `ping` requests with an empty result, but `MCP::Client` had no public API to send a `ping` request. Users had to build the JSON-RPC payload themselves and call the transport directly. This adds a client-side counterpart to the existing server handler and aligns the Ruby SDK in spirit with the Python and TypeScript SDKs, which expose `ClientSession.send_ping` and `Client.ping` respectively. Those SDKs validate the response envelope via type schemas (`EmptyResult` / `EmptyResultSchema`) and raise their validation library's generic exception (Pydantic's `ValidationError` / Zod's `ZodError`). The Ruby client applies a Hash type check on `result` (matching Python's behavior of requiring a dict while allowing arbitrary contents) and raises a new `MCP::Client::ValidationError` for malformed responses, keeping `MCP::Client::ServerError` reserved for JSON-RPC error responses. ## How Has This Been Tested? Client tests cover: - request structure (method is `ping`, no `params`) - empty result on success - `ServerError` on JSON-RPC error response - `ValidationError` on a response missing `result` - `ValidationError` on a `result` of the wrong type (e.g., String) - propagation of transport-level errors raised by `transport.send_request` The existing server-side `ping` handler tests continue to pass unchanged. ## Breaking Changes None. `MCP::Client#ping` and `MCP::Client::ValidationError` are purely additive. The server-side `ping` handler behavior is unchanged. --- README.md | 28 +++++++++++++++++++ lib/mcp/client.rb | 23 ++++++++++++++++ test/mcp/client_test.rb | 61 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/README.md b/README.md index 202b401..bd6e504 100644 --- a/README.md +++ b/README.md @@ -1099,6 +1099,33 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names: - `notifications/progress` - `notifications/message` +### Ping + +The MCP Ruby SDK supports the +[MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), +which allows either side of the connection to verify that the peer is still responsive. +A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. + +#### Server-Side + +Servers respond to incoming `ping` requests automatically - no setup is required. +Any `MCP::Server` instance replies with an empty result. + +#### Client-Side + +`MCP::Client` exposes `ping` to send a ping to the server: + +```ruby +client = MCP::Client.new(transport: transport) +client.ping # => {} on success +``` + +`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. +It raises `MCP::Client::ValidationError` when the response `result` is missing or +is not a Hash (matching the spec requirement that `result` be an object). +Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) +propagate as exceptions raised by the transport layer. + ### Progress The MCP Ruby SDK supports progress tracking for long-running tool operations, @@ -1549,6 +1576,7 @@ The `MCP::Client` class provides an interface for interacting with MCP servers. This class supports: +- Liveness check via the `ping` method (`MCP::Client#ping`) - Tool listing via the `tools/list` method (`MCP::Client#tools`) - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`) - Resource listing via the `resources/list` method (`MCP::Client#resources`) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index e4cfd02..e7c2b8c 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -28,6 +28,11 @@ module MCP end end + # Raised when a server response fails client-side validation, e.g., a success response + # whose `result` field is missing or has the wrong type. This is distinct from a + # server-returned JSON-RPC error, which is raised as `ServerError`. + class ValidationError < StandardError; end + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. @@ -303,6 +308,24 @@ module MCP response.dig("result", "completion") || { "values" => [], "hasMore" => false } end + # Sends a `ping` request to the server to verify the connection is alive. + # Per the MCP spec, the server responds with an empty result. + # + # @return [Hash] An empty hash on success. + # @raise [ServerError] If the server returns a JSON-RPC error. + # @raise [ValidationError] If the response `result` is missing or not a Hash. + # + # @example + # client.ping # => {} + # + # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping + def ping + result = request(method: Methods::PING)["result"] + raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) + + result + end + private def request(method:, params: nil) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 5414469..4142ffd 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -538,6 +538,67 @@ module MCP refute(result["hasMore"]) end + def test_ping_sends_request_and_returns_empty_hash + transport = mock + mock_response = { "result" => {} } + + transport.expects(:send_request).with do |args| + req = args[:request] + req[:method] == "ping" && + req[:jsonrpc] == "2.0" && + !req.key?(:params) + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.ping + + assert_equal({}, result) + end + + def test_ping_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_603, "message" => "Internal error" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.ping } + assert_equal(-32_603, error.code) + assert_equal("Internal error", error.message) + end + + def test_ping_raises_validation_error_when_result_is_missing + transport = mock + mock_response = {} + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_raises_validation_error_when_result_is_wrong_type + transport = mock + mock_response = { "result" => "ok" } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_propagates_transport_errors + transport = mock + transport_error = StandardError.new("read timeout") + transport.expects(:send_request).raises(transport_error).once + + client = Client.new(transport: transport) + error = assert_raises(StandardError) { client.ping } + assert_equal("read timeout", error.message) + end + def test_tools_auto_paginates_across_multiple_pages transport = mock From b832933f9fd51d737701c6b33bab748b549f8d14 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sun, 19 Apr 2026 11:54:03 -0400 Subject: [PATCH 051/247] Parse SSE responses in HTTP client via event_stream_parser The Streamable HTTP spec allows servers to respond with either `application/json` or `text/event-stream`. The client previously rejected the latter. Add SSE parsing via the optional `event_stream_parser` gem (users add it to their Gemfile when their server uses SSE), dispatch on the response Content-Type, and scan the event stream for the first JSON-RPC response message. Document the new optional dependency in both the README's HTTP Transport Layer section and docs/building-clients.md. No other Streamable HTTP features (202 handling, session IDs, `connect`, DELETE termination) are included here; those will land in follow-up PRs. Co-Authored-By: Claude Opus 4.7 --- Gemfile | 1 + README.md | 3 +- docs/building-clients.md | 3 +- lib/mcp/client/http.rb | 49 +++++++++++++-- test/mcp/client/http_test.rb | 112 +++++++++++++++++++++++++++++++++-- 5 files changed, 156 insertions(+), 12 deletions(-) diff --git a/Gemfile b/Gemfile index 5166516..8b76222 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem "yard", "~> 0.9" gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1" group :test do + gem "event_stream_parser", ">= 1.0" gem "faraday", ">= 2.0" gem "minitest", "~> 5.1", require: false gem "mocha" diff --git a/README.md b/README.md index 866c213..6ef1c08 100644 --- a/README.md +++ b/README.md @@ -1599,11 +1599,12 @@ The stdio transport automatically handles: Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. -You'll need to add `faraday` as a dependency in order to use the HTTP transport layer: +You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: ```ruby gem 'mcp' gem 'faraday', '>= 2.0' +gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses ``` Example usage: diff --git a/docs/building-clients.md b/docs/building-clients.md index 45c7e8d..48352ee 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -51,11 +51,12 @@ stdio_transport.close ## HTTP Transport -Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem: +Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE (`text/event-stream`) responses: ```ruby gem 'mcp' gem 'faraday', '>= 2.0' +gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses ``` ```ruby diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 1637b0e..8b135e3 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -18,8 +18,7 @@ module MCP params = request[:params] || request["params"] response = client.post("", request) - validate_response_content_type!(response, method, params) - response.body + parse_response_body(response, method, params) rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -92,14 +91,52 @@ module MCP "See https://rubygems.org/gems/faraday for more details." end - def validate_response_content_type!(response, method, params) + def require_event_stream_parser! + require "event_stream_parser" + rescue LoadError + raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \ + "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \ + "See https://rubygems.org/gems/event_stream_parser for more details." + end + + def parse_response_body(response, method, params) content_type = response.headers["Content-Type"] - return if content_type&.include?("application/json") + + if content_type&.include?("text/event-stream") + parse_sse_response(response.body, method, params) + elsif content_type&.include?("application/json") + response.body + else + raise RequestHandlerError.new( + "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.", + { method: method, params: params }, + error_type: :unsupported_media_type, + ) + end + end + + def parse_sse_response(body, method, params) + require_event_stream_parser! + + json_rpc_response = nil + parser = EventStreamParser::Parser.new + parser.feed(body.to_s) do |_type, data, _id| + next if data.empty? + + begin + parsed = JSON.parse(data) + json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error")) + rescue JSON::ParserError + next + end + end + + return json_rpc_response if json_rpc_response raise RequestHandlerError.new( - "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.", + "No valid JSON-RPC response found in SSE stream", { method: method, params: params }, - error_type: :unsupported_media_type, + error_type: :parse_error, ) end end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 081f0e6..89d3649 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "event_stream_parser" require "faraday" require "webmock/minitest" require "mcp/client/http" @@ -25,6 +26,26 @@ module MCP assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'") end + def test_raises_load_error_when_event_stream_parser_not_available + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: {}\n\n", + ) + + HTTP.any_instance.stubs(:require).with("faraday").returns(true) + HTTP.any_instance.stubs(:require).with("event_stream_parser") + .raises(LoadError, "cannot load such file -- event_stream_parser") + + error = assert_raises(LoadError) do + client.send_request(request: { method: "tools/list" }) + end + + assert_includes(error.message, "The 'event_stream_parser' gem is required to parse SSE responses") + assert_includes(error.message, "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'") + end + def test_headers_are_added_to_the_request headers = { "Authorization" => "Bearer token" } client = HTTP.new(url: url, headers: headers) @@ -267,7 +288,7 @@ module MCP custom_client.send_request(request: request) end - def test_send_request_raises_error_for_non_json_response + def test_send_request_raises_error_for_unsupported_content_type request = { jsonrpc: "2.0", id: "test_id", @@ -278,8 +299,8 @@ module MCP .with(body: request.to_json) .to_return( status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: "data: {}\n\n", + headers: { "Content-Type" => "text/html" }, + body: "", ) error = assert_raises(RequestHandlerError) do @@ -287,13 +308,96 @@ module MCP end assert_equal( - 'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.', + 'Unsupported Content-Type: "text/html". Expected application/json or text/event-stream.', error.message, ) assert_equal(:unsupported_media_type, error.error_type) assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_send_request_parses_sse_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + : comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[{"name":"echo"}]}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal({ "tools" => [{ "name" => "echo" }] }, response["result"]) + end + + def test_send_request_parses_sse_error_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + data: {"jsonrpc":"2.0","id":"test_id","error":{"code":-32600,"message":"Invalid request"}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal(-32600, response.dig("error", "code")) + assert_equal("Invalid request", response.dig("error", "message")) + end + + def test_send_request_raises_error_for_sse_without_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + : just a comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "No valid JSON-RPC response found in SSE stream") + assert_equal(:parse_error, error.error_type) + end + private def stub_request(method, url) From cfc42dbfe7478aefd451ddc35783d290907f2314 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sat, 18 Apr 2026 19:42:19 -0400 Subject: [PATCH 052/247] Handle 202 Accepted response in HTTP client Per the Streamable HTTP spec, a server returns 202 Accepted with no body as the ACK for a JSON-RPC notification or response. The client previously errored on 202 because it fell through Content-Type dispatch. Short-circuit to return nil before Content-Type handling so fire-and-forget messages (e.g. notifications/initialized) no longer raise. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server Co-Authored-By: Claude Opus 4.7 --- examples/streamable_http_server.rb | 2 +- lib/mcp/client/http.rb | 4 ++++ test/mcp/client/http_test.rb | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index afe48c4..997b606 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -171,7 +171,7 @@ puts <<~MESSAGE curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}' - Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {"accepted": true} + Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return 202 Accepted with no body. Press Ctrl+C to stop the server MESSAGE diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 8b135e3..d0350af 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -100,6 +100,10 @@ module MCP end def parse_response_body(response, method, params) + # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + return if response.status == 202 + content_type = response.headers["Content-Type"] if content_type&.include?("text/event-stream") diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 89d3649..8ec0435 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -369,6 +369,21 @@ module MCP assert_equal("Invalid request", response.dig("error", "message")) end + def test_send_request_returns_nil_for_202_accepted_response + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_nil(response) + end + def test_send_request_raises_error_for_sse_without_response request = { jsonrpc: "2.0", From 07226b7cf16e682bf5a1aa3dcf0a4c04d006f9e3 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sun, 19 Apr 2026 23:29:00 -0400 Subject: [PATCH 053/247] Track Mcp-Session-Id and protocol version in HTTP client Per the Streamable HTTP spec, if the server returns an Mcp-Session-Id header during `initialize`, the client MUST include it on subsequent requests. Capture the session ID and protocol version from the `initialize` response and attach them automatically on subsequent POSTs. Per the Protocol Version Header section of the spec, the client MUST also include `MCP-Protocol-Version` on all subsequent requests so the server can respond based on the negotiated protocol version. Map 404 responses to a new `SessionExpiredError` (a subclass of `RequestHandlerError` for backward compatibility) and clear local session state so callers can start a fresh session with a new `initialize` request, as required by the spec. - https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management - https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header Co-Authored-By: Claude Opus 4.7 --- docs/building-clients.md | 11 ++ lib/mcp/client.rb | 10 ++ lib/mcp/client/http.rb | 74 ++++++++++++-- test/mcp/client/http_test.rb | 190 ++++++++++++++++++++++++++++++++++- 4 files changed, 275 insertions(+), 10 deletions(-) diff --git a/docs/building-clients.md b/docs/building-clients.md index 48352ee..272ec32 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -74,6 +74,17 @@ response = client.call_tool( ) ``` +### Sessions + +After a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport: + +```ruby +http_transport.session_id # => "abc123..." +http_transport.protocol_version # => "2025-11-25" +``` + +If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request. + ### Authorization Provide custom headers for authentication: diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index e7c2b8c..31057ed 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -33,6 +33,16 @@ module MCP # server-returned JSON-RPC error, which is raised as `ServerError`. class ValidationError < StandardError; end + # Raised when the server responds 404 to a request containing a session ID, + # indicating the session has expired. Inherits from `RequestHandlerError` for + # backward compatibility with callers that rescue the generic error. Per spec, + # clients MUST start a new session with a fresh `initialize` request in response. + class SessionExpiredError < RequestHandlerError + def initialize(message, request, original_error: nil) + super(message, request, error_type: :not_found, original_error: original_error) + end + end + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index d0350af..9b85d7d 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,24 +1,42 @@ # frozen_string_literal: true +require_relative "../methods" + module MCP class Client + # TODO: HTTP GET for SSE streaming is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server + # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery class HTTP ACCEPT_HEADER = "application/json, text/event-stream" + SESSION_ID_HEADER = "Mcp-Session-Id" + PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" - attr_reader :url + attr_reader :url, :session_id, :protocol_version def initialize(url:, headers: {}, &block) @url = url @headers = headers @faraday_customizer = block + @session_id = nil + @protocol_version = nil end + # Sends a JSON-RPC request and returns the parsed response body. + # After a successful `initialize` handshake, the session ID and protocol + # version returned by the server are captured and automatically included + # on subsequent requests. def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] - response = client.post("", request) - parse_response_body(response, method, params) + response = client.post("", request, session_headers) + body = parse_response_body(response, method, params) + + capture_session_info(method, response, body) + + body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -41,12 +59,25 @@ module MCP original_error: e, ) rescue Faraday::ResourceNotFound => e - raise RequestHandlerError.new( - "The #{method} request is not found", - { method: method, params: params }, - error_type: :not_found, - original_error: e, - ) + # Per spec, 404 is the session-expired signal only when the request + # actually carried an `Mcp-Session-Id`. A 404 without a session attached + # (e.g. wrong URL or a stateless server) surfaces as a generic not-found. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + if @session_id + clear_session + raise SessionExpiredError.new( + "The #{method} request is not found", + { method: method, params: params }, + original_error: e, + ) + else + raise RequestHandlerError.new( + "The #{method} request is not found", + { method: method, params: params }, + error_type: :not_found, + original_error: e, + ) + end rescue Faraday::UnprocessableEntityError => e raise RequestHandlerError.new( "The #{method} request is unprocessable", @@ -83,6 +114,31 @@ module MCP end end + # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one) + # and `MCP-Protocol-Version` on all requests after `initialize`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header + def session_headers + request_headers = {} + request_headers[SESSION_ID_HEADER] = @session_id if @session_id + request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version + request_headers + end + + def capture_session_info(method, response, body) + return unless method.to_s == Methods::INITIALIZE + + # Faraday normalizes header names to lowercase. + session_id = response.headers[SESSION_ID_HEADER.downcase] + @session_id ||= session_id unless session_id.to_s.empty? + @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil + end + + def clear_session + @session_id = nil + @protocol_version = nil + end + def require_faraday! require "faraday" rescue LoadError diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 8ec0435..a4dcbc8 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -203,7 +203,7 @@ module MCP assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_not_found_error + def test_send_request_raises_not_found_error_on_404_without_session request = { jsonrpc: "2.0", id: "test_id", @@ -218,11 +218,56 @@ module MCP client.send_request(request: request) end + refute_kind_of(SessionExpiredError, error) assert_equal("The tools/list request is not found", error.message) assert_equal(:not_found, error.error_type) assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_send_request_raises_session_expired_error_on_404_with_session + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_equal(:not_found, error.error_type) + end + + def test_session_expired_error_is_a_request_handler_error + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_kind_of(SessionExpiredError, error) + end + def test_send_request_raises_unprocessable_entity_error request = { jsonrpc: "2.0", @@ -413,6 +458,149 @@ module MCP assert_equal(:parse_error, error.error_type) end + def test_captures_session_id_and_protocol_version_on_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_includes_session_and_protocol_version_headers_after_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url) + .with( + headers: { + "Mcp-Session-Id" => "session-abc", + "MCP-Protocol-Version" => "2025-11-25", + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + def test_does_not_send_protocol_version_header_before_initialize + stub_request(:post, url) + .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + end + + def test_ignores_empty_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + end + + def test_session_id_not_overwritten_by_subsequent_responses + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "original-session", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("original-session", client.session_id) + + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "different-session", + }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_equal("original-session", client.session_id) + end + + def test_stateless_server_without_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_clears_session_state_on_404 + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + + stub_request(:post, url).to_return(status: 404) + + assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + private def stub_request(method, url) From ff3a42f3fbf3b93950d20e0bc9b41eec1e6047ed Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 20 Apr 2026 13:08:28 -0400 Subject: [PATCH 054/247] Add HTTP client close for explicit session termination Per MCP spec, clients that no longer need a session SHOULD send an HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly terminate it. Servers MAY respond with 405 Method Not Allowed when they don't support client-initiated termination. `MCP::Client::HTTP#close` sends the DELETE with session headers, clears local session state regardless of outcome, and is a no-op when no session has been established. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management --- docs/building-clients.md | 6 ++ lib/mcp/client/http.rb | 22 ++++++ test/mcp/client/http_test.rb | 129 +++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/docs/building-clients.md b/docs/building-clients.md index 272ec32..d39beda 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -85,6 +85,12 @@ http_transport.protocol_version # => "2025-11-25" If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request. +To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. + +```ruby +http_transport.close +``` + ### Authorization Provide custom headers for authentication: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 9b85d7d..c0d7247 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -94,6 +94,28 @@ module MCP ) end + # Terminates the session by sending an HTTP DELETE to the MCP endpoint + # with the current `Mcp-Session-Id` header, and clears locally tracked + # session state afterward. No-op when no session has been established. + # + # Per spec, the server MAY respond with HTTP 405 Method Not Allowed when + # it does not support client-initiated termination, and returns 404 for + # a session it has already terminated. Both mean the session is gone — + # the desired end state. Other errors surface to the caller; local + # session state is cleared either way. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + def close + return unless @session_id + + begin + client.delete("", nil, session_headers) + rescue Faraday::ClientError => e + raise unless [404, 405].include?(e.response&.dig(:status)) + ensure + clear_session + end + end + private attr_reader :headers diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index a4dcbc8..19917dd 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -601,8 +601,137 @@ module MCP assert_nil(client.protocol_version) end + def test_close_sends_delete_with_session_headers + initialize_session + + stub_request(:delete, url) + .with( + headers: { + "Mcp-Session-Id" => "session-abc", + "MCP-Protocol-Version" => "2025-11-25", + }, + ) + .to_return(status: 200) + + client.close + end + + def test_close_clears_session_state + initialize_session + stub_request(:delete, url).to_return(status: 200) + + client.close + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_without_session_is_noop + client.close + + assert_not_requested(:delete, url) + assert_nil(client.session_id) + end + + def test_close_tolerates_405_response + initialize_session + stub_request(:delete, url).to_return(status: 405) + + client.close + + assert_nil(client.session_id) + end + + def test_close_tolerates_404_response + initialize_session + stub_request(:delete, url).to_return(status: 404) + + client.close + + assert_nil(client.session_id) + end + + def test_close_propagates_server_error_and_still_clears_state + initialize_session + stub_request(:delete, url).to_return(status: 500) + + assert_raises(Faraday::ServerError) do + client.close + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_propagates_unauthorized_and_still_clears_state + initialize_session + stub_request(:delete, url).to_return(status: 401) + + assert_raises(Faraday::UnauthorizedError) do + client.close + end + + assert_nil(client.session_id) + end + + def test_close_propagates_connection_failure_and_still_clears_state + initialize_session + stub_request(:delete, url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + + assert_raises(Faraday::ConnectionFailed) do + client.close + end + + assert_nil(client.session_id) + end + + def test_close_is_idempotent + initialize_session + stub_request(:delete, url).to_return(status: 200) + + client.close + client.close + + assert_requested(:delete, url, times: 1) + end + + def test_close_allows_reinitializing_a_fresh_session + initialize_session + stub_request(:delete, url).to_return(status: 200) + client.close + + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-xyz", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" }) + + assert_equal("session-xyz", client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + private + def initialize_session + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + end + def stub_request(method, url) WebMock.stub_request(method, url) end From 017b0bb4c910f8a94841cd65f1270645d6a4adad Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Apr 2026 20:12:52 +0900 Subject: [PATCH 055/247] Support JSON response mode for `StreamableHTTPTransport` ## Motivation and Context The MCP Streamable HTTP specification allows servers to return POST responses as either `text/event-stream` (SSE) or `application/json`: > If the input is a JSON-RPC request, the server MUST either return `Content-Type: text/event-stream`, > to initiate an SSE stream, or `Content-Type: application/json`, to return one JSON object. See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server The TypeScript and Python SDKs support a configurable JSON response mode via `enableJsonResponse` / `is_json_response_enabled`. JSON response mode is suitable for simple tool servers that do not need server-initiated requests. It avoids SSE framing overhead and returns a single JSON object for the POST response. ## Behavior - POST responses use `Content-Type: application/json` and return a single JSON object. - The POST `Accept` header requirement is relaxed to `application/json` only (matching the Python SDK's lenient behavior). - Request-scoped notifications (`progress`, `log`) cannot ride along with the single-object response and are silently dropped. - Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and broadcast notifications (`tools/list_changed`, etc.) continue to flow to clients connected to the GET SSE stream. - All server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error in JSON response mode. - Combines with `stateless: true` for simple single-response servers without session tracking. ## How Has This Been Tested? Added tests for JSON response mode: - POST request returns `application/json` response - Accept header validation requires only `application/json` - Returns 406 when Accept header is missing - Accepts wildcard `*/*` in Accept header - Request-scoped notifications (progress, log) during tool execution are silently dropped - Request-scoped notifications do not leak to GET SSE even when connected - Session-scoped standalone notifications are delivered via GET SSE - Broadcast notifications are delivered via GET SSE - `send_request` raises for `sampling/createMessage`, `roots/list`, `elicitation/create` - Combined with `stateless: true`: POST returns JSON without a session ID, GET returns 405 ## Breaking Changes None. JSON response mode is opt-in via `enable_json_response: true`. The default behavior (SSE responses) is unchanged. --- README.md | 16 + .../transports/streamable_http_transport.rb | 21 +- .../streamable_http_transport_test.rb | 415 ++++++++++++++++++ 3 files changed, 448 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 40a64f1..8cbffb7 100644 --- a/README.md +++ b/README.md @@ -1425,6 +1425,22 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) ``` +You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. +Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: + +```ruby +# JSON response mode +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true) +``` + +In JSON response mode, the POST response is a single JSON object, so server-to-client messages +that need to arrive during request processing are not supported: +request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests +(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. +Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and +broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream. +This mode is suitable for simple tool servers that do not need server-initiated requests. + By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds). When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up: diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 5c6c068..284e758 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -22,13 +22,14 @@ module MCP "Connection" => "keep-alive", }.freeze - def initialize(server, stateless: false, session_idle_timeout: nil) + def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @mutex = Mutex.new @stateless = stateless + @enable_json_response = enable_json_response @session_idle_timeout = session_idle_timeout @pending_responses = {} @@ -43,7 +44,8 @@ module MCP start_reaper_thread if @session_idle_timeout end - REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze + REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze + REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze SESSION_REAP_INTERVAL = 60 @@ -94,6 +96,12 @@ module MCP result = @mutex.synchronize do if session_id + # JSON response mode returns a single JSON object as the POST response, + # so request-scoped notifications (e.g. progress, log) cannot be delivered + # alongside it. Session-scoped standalone notifications + # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE. + next false if @enable_json_response && related_request_id + # Send to specific session if (session = @sessions[session_id]) stream = active_stream(session, related_request_id: related_request_id) @@ -172,6 +180,10 @@ module MCP raise "Stateless mode does not support server-to-client requests." end + if @enable_json_response + raise "JSON response mode does not support server-to-client requests." + end + unless session_id raise "session_id is required for server-to-client requests." end @@ -278,7 +290,8 @@ module MCP end def handle_post(request) - accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES) + required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE + accept_error = validate_accept_header(request, required_types) return accept_error if accept_error content_type_error = validate_content_type(request) @@ -519,7 +532,7 @@ module MCP end end - if session_id && !@stateless + if session_id && !@stateless && !@enable_json_response handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) else response = dispatch_handle_json(body_string, server_session) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index d3d85f4..6c1983d 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1549,6 +1549,421 @@ module MCP assert_equal "456", body["id"] end + test "JSON response mode returns application/json for POST requests" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + + body = JSON.parse(response[2][0]) + assert_equal("1", body["id"]) + ensure + transport.close + end + + test "JSON response mode accepts application/json only in Accept header" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "JSON response mode returns 406 when Accept header is missing" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(406, response[0]) + ensure + transport.close + end + + test "JSON response mode accepts wildcard Accept header" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "*/*", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "JSON response mode drops notifications during tool execution" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "should be dropped", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + + body = JSON.parse(response[2][0]) + assert_equal("call-1", body["id"]) + refute_includes(response[2][0], "should be dropped") + ensure + transport.close + end + + test "JSON response mode drops notifications even with GET SSE connected" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "should not leak", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Call tool that sends a notification. + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + + # Notification should NOT leak to GET SSE stream. + io.rewind + refute_includes(io.read, "should not leak") + ensure + transport.close + end + + test "JSON response mode raises on send_request (sampling)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request("sampling/createMessage", { messages: [] }, session_id: "s1") + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode raises on send_request (roots/list)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request("roots/list", nil, session_id: "s1") + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode raises on send_request (elicitation/create)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request( + "elicitation/create", + { mode: "form", message: "test", requestedSchema: {} }, + session_id: "s1", + ) + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode allows broadcast notifications via GET SSE" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + server.server_context = server + + server.define_tool(name: "notify_tool") do |server_context:| + server_context.notify_tools_list_changed + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Call tool that triggers a broadcast notification. + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "notify_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + + # Broadcast notification should arrive on GET SSE stream. + io.rewind + assert_includes(io.read, "notifications/tools/list_changed") + ensure + transport.close + end + + test "JSON response mode with stateless returns application/json without session ID" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) + server.transport = transport + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + assert_nil(response[1]["Mcp-Session-Id"]) + + body = JSON.parse(response[2][0]) + assert_equal("1", body["id"]) + ensure + transport.close + end + + test "JSON response mode with stateless returns 405 on GET" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) + server.transport = transport + + request = create_rack_request("GET", "/", {}) + + response = transport.handle_request(request) + assert_equal(405, response[0]) + ensure + transport.close + end + + test "JSON response mode delivers session-scoped standalone notifications via GET SSE" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + server.server_context = server + + server.define_tool(name: "touch_tool") do |server_context:| + server_context.notify_resources_updated(uri: "file:///example.txt") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "touch_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["Content-Type"]) + + io.rewind + assert_includes(io.read, "notifications/resources/updated") + ensure + transport.close + end + test "handle post request with a standard error" do request = create_rack_request( "POST", From b03ec40d23274ff39967f7cfa1acb5aa21fc1586 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 24 Apr 2026 11:27:27 +0900 Subject: [PATCH 056/247] Release 0.14.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 20 ++++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0e96d..2f9baa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-04-24 + +### Added + +- Support pagination per MCP specification (#320) +- Support resource subscriptions per MCP specification (#313) +- Add `roots/list` and `notifications/roots/list_changed` support (#315) +- Support JSON response mode for `StreamableHTTPTransport` (#328) +- Add HTTP client close for explicit session termination (#326) +- Track `Mcp-Session-Id` and protocol version in HTTP client (#325) +- Support `ping` client API per MCP specification (#324) + +### Fixed + +- Handle 202 Accepted response in HTTP client (#323) + +### Changed + +- Parse SSE responses in HTTP client via `event_stream_parser` (#322) + ## [0.13.0] - 2026-04-16 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 8378006..885588f 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.13.0" + VERSION = "0.14.0" end From 07fb0a8e567a1ab73158a537eeef3a334e5c88f6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 23 Mar 2026 03:37:03 +0900 Subject: [PATCH 057/247] [Doc] Fix a typo in README.md The method name in the client feature list was `MCP::Client#read_resources` (plural), but the actual method is `MCP::Client#read_resource` (singular). --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ac29be..dfed355 100644 --- a/README.md +++ b/README.md @@ -1051,7 +1051,7 @@ This class supports: - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`) - Resource listing via the `resources/list` method (`MCP::Client#resources`) - Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) -- Resource reading via the `resources/read` method (`MCP::Client#read_resources`) +- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) - Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) - Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) - Automatic JSON-RPC 2.0 message formatting From ceea440ca587ea83e86fcfa9882530350477d605 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 13 Apr 2026 02:31:26 +0900 Subject: [PATCH 058/247] Remove obsolete `MCP::Transports` module ## Motivation and Context `lib/mcp/transports/stdio.rb` was a deprecated shim that redirected users from `MCP::Transports::StdioTransport` to `MCP::Server::Transports::StdioTransport`. It has been 10 months since the migration path was introduced, providing sufficient time for users to update their code. ## How Has This Been Tested? All tests pass (`rake test`), and no code in the repository references `MCP::Transports` or `mcp/transports/stdio`. ## Breaking Changes `require "mcp/transports/stdio"` and `MCP::Transports::StdioTransport` are removed. Use `require "mcp/server/transports/stdio_transport"` and `MCP::Server::Transports::StdioTransport` instead. --- lib/mcp/transports/stdio.rb | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 lib/mcp/transports/stdio.rb diff --git a/lib/mcp/transports/stdio.rb b/lib/mcp/transports/stdio.rb deleted file mode 100644 index 3050f26..0000000 --- a/lib/mcp/transports/stdio.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative "../server/transports/stdio_transport" - -warn <<~MESSAGE, uplevel: 3 - Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`. - Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`. - This API is deprecated and will be removed in a future release. -MESSAGE - -module MCP - module Transports - StdioTransport = Server::Transports::StdioTransport - end -end From 48d4fb92724a5bd6b5fa7dd049f19eb3b549df0c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 1 May 2026 02:13:08 +0900 Subject: [PATCH 059/247] Return tool argument validation failures as tool execution errors ## Motivation and Context MCP 2025-11-25 (SEP-1303) clarifies that input validation errors for `tools/call` should be returned as Tool Execution Errors (`{ content: [...], isError: true }`) rather than as JSON-RPC `-32602` protocol errors, so models can observe the validation message and self-correct on a follow-up call. https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1303 This is a clarification rather than a brand-new requirement: the 2024-11-05, 2025-03-26, and 2025-06-18 specifications all defined two error categories with overlapping language ("Invalid arguments" listed under Protocol Errors *and* "Invalid input data" listed under Tool Execution Errors), leaving the routing of JSON-Schema validation failures ambiguous. The Ruby SDK had selected the "Protocol Errors / Invalid arguments" interpretation, while the TypeScript SDK (`packages/server/src/server/mcp.ts`, which wraps `validateToolInput` errors via `createToolError`) and Python SDK's FastMCP (which routes through the generic `is_error=True` path) had selected the other. SEP-1303 in 2025-11-25 disambiguates this by replacing the bullets with "Malformed requests (requests that fail to satisfy CallToolRequest schema)" under Protocol Errors and "Input validation errors (e.g., date in wrong format, value out of range)" under Tool Execution Errors, and explicitly states that the latter "contain actionable feedback that language models can use to self-correct". `tool_not_found` continues to be returned as a JSON-RPC `-32602` protocol error since the spec change only covers input validation. ## How Has This Been Tested? Updated existing tests that previously asserted `-32602` and "Invalid arguments" / "Missing required arguments" in the JSON-RPC error data to instead assert `result[:isError] == true` with the same text in the `content` block. Added new regression tests covering: - Instrumentation still records `:missing_required_arguments` and `:invalid_schema` on the new non-raising path - Nested schema validation failure (deep arrays of objects with required fields) returns a tool execution error rather than a protocol error - `tool_not_found` continues to return JSON-RPC `-32602` (regression guard against accidentally widening the change) `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes Yes. Clients that detect tool argument validation errors via `error.code == -32602` will need to switch to inspecting `result.isError == true` and reading `result.content[].text`. Note that the Ruby SDK's previous behavior was a defensible reading of the 2024-11-05 / 2025-03-26 / 2025-06-18 spec wording. The 2025-11-25 disambiguation is what makes the previous behavior non-conforming; the TypeScript and Python SDKs already shipped the new behavior. --- lib/mcp/server.rb | 4 +- test/mcp/server_test.rb | 111 +++++++++++++++++++++++++++++++--------- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index b687fc5..49b4fb7 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -533,7 +533,7 @@ module MCP add_instrumentation_data(error: :missing_required_arguments) missing = tool.input_schema.missing_required_arguments(arguments).join(", ") - raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params) + return error_tool_response("Missing required arguments: #{missing}") end if configuration.validate_tool_call_arguments && tool.input_schema @@ -542,7 +542,7 @@ module MCP rescue Tool::InputSchema::ValidationError => e add_instrumentation_data(error: :invalid_schema) - raise RequestHandlerError.new(e.message, request, error_type: :invalid_params) + return error_tool_response(e.message) end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 5d9186a..87668eb 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -312,7 +312,7 @@ module MCP assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end - test "#handle tools/call returns protocol error in JSON-RPC format if required tool arguments are missing" do + test "#handle tools/call returns tool execution error if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", title: "Test tool", @@ -336,10 +336,10 @@ module MCP response = server.handle(request) - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Missing required arguments: message" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments: message" end test "#handle_json tools/call executes tool and returns result" do @@ -1562,11 +1562,10 @@ module MCP refute response[:result].key?(:instructions) end - test "tools/call returns protocol error in JSON-RPC format for missing arguments" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), - ) + test "tools/call returns tool execution error for missing arguments" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { @@ -1581,17 +1580,22 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Missing required arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: {}, + error: :missing_required_arguments, + }) end - test "tools/call returns protocol error in JSON-RPC format for invalid arguments when validate_tool_call_arguments is true" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), - ) + test "tools/call returns tool execution error for invalid arguments when validate_tool_call_arguments is true" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { @@ -1607,10 +1611,44 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_nil response[:result] - assert_equal(-32602, response[:error][:code]) - assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Invalid arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: { message: 123 }, + error: :invalid_schema, + }) + end + + test "tools/call returns tool execution error for nested schema validation failure" do + server = Server.new( + tools: [ComplexTypesTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "complex_types_tool", + arguments: { + numbers: [1, 2, 3], + strings: ["a", "b", "c"], + objects: [{ name: 123 }], + }, + }, + }, + ) + + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" end test "tools/call skips argument validation when validate_tool_call_arguments is false" do @@ -1695,7 +1733,7 @@ module MCP assert_equal "OK", response[:result][:content][0][:content] end - test "tools/call returns protocol error in JSON-RPC format when additionalProperties set to false" do + test "tools/call returns tool execution error when additionalProperties set to false" do server = Server.new( tools: [TestToolWithAdditionalPropertiesSetToFalse], configuration: Configuration.new(validate_tool_call_arguments: true), @@ -1718,10 +1756,33 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + end + + test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do + server = Server.new( + tools: [TestTool], + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "unknown_tool", + arguments: {}, + }, + }, + ) + assert_nil response[:result] assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] - assert_includes response[:error][:data], "Invalid arguments" + assert_includes response[:error][:data], "Tool not found: unknown_tool" end test "#handle completion/complete returns default completion result" do From ddfbfad81e6faa3c874b8a26623e1e3899d4db3e Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Fri, 1 May 2026 20:14:33 -0400 Subject: [PATCH 060/247] Add client-level connect for initialize handshake --- docs/building-clients.md | 19 ++- lib/mcp/client.rb | 42 ++++++ lib/mcp/client/http.rb | 101 ++++++++++++++- test/mcp/client/http_test.rb | 242 +++++++++++++++++++++++++++++++++++ test/mcp/client_test.rb | 71 ++++++++++ 5 files changed, 471 insertions(+), 4 deletions(-) diff --git a/docs/building-clients.md b/docs/building-clients.md index d39beda..1fa817a 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -62,6 +62,7 @@ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses ```ruby http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") client = MCP::Client.new(transport: http_transport) +client.connect tools = client.tools tools.each do |tool| @@ -74,16 +75,30 @@ response = client.call_tool( ) ``` +### Handshake + +Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +client.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +client.connected? # => true +client.server_info # => cached InitializeResult +``` + +`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. + ### Sessions -After a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport: +After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: ```ruby http_transport.session_id # => "abc123..." http_transport.protocol_version # => "2025-11-25" ``` -If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request. +If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 31057ed..7cf5983 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -59,6 +59,48 @@ module MCP # So keeping it public attr_reader :transport + # The server's `InitializeResult` (protocol version, capabilities, server info, + # instructions), as reported by the transport after a successful `connect`. + # Returns `nil` before `connect`, after `close`, or when the transport manages + # the handshake implicitly and does not expose it (e.g. stdio). + def server_info + transport.server_info if transport.respond_to?(:server_info) + end + + # Performs the MCP `initialize` handshake by delegating to the transport when + # it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the + # server's `InitializeResult`. + # + # When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio` + # manages the handshake implicitly on the first request), this is a no-op and + # returns `nil`. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # @param protocol_version [String, nil] Protocol version to offer. + # @param capabilities [Hash] Capabilities advertised by the client. + # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport + # does not expose an explicit handshake. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}) + return unless transport.respond_to?(:connect) + + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + ) + end + + # Returns true once `connect` has completed the handshake on transports that + # expose connection state. Transports that manage the handshake implicitly + # (e.g. stdio) always report `true`, since the first request will initialize + # on demand. + def connected? + return transport.connected? if transport.respond_to?(:connected?) + + true + end + # Returns a single page of tools from the server. # # @param cursor [String, nil] Cursor from a previous page response. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index c0d7247..3eaabee 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require "securerandom" +require_relative "../../json_rpc_handler" +require_relative "../configuration" require_relative "../methods" +require_relative "../version" module MCP class Client @@ -13,7 +17,7 @@ module MCP SESSION_ID_HEADER = "Mcp-Session-Id" PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" - attr_reader :url, :session_id, :protocol_version + attr_reader :url, :session_id, :protocol_version, :server_info def initialize(url:, headers: {}, &block) @url = url @@ -21,6 +25,94 @@ module MCP @faraday_customizer = block @session_id = nil @protocol_version = nil + @server_info = nil + @connected = false + end + + # Performs the MCP `initialize` handshake: sends an `initialize` request + # followed by the required `notifications/initialized` notification. The + # server's `InitializeResult` (protocol version, capabilities, server + # info, instructions) is cached on the transport and returned. + # + # Idempotent: a second call returns the cached `InitializeResult` without + # contacting the server. After `close`, state is cleared and `connect` + # will handshake again. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. + # @param protocol_version [String, nil] Protocol version to offer. Defaults + # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. + # @return [Hash] The server's `InitializeResult`. + # @raise [RequestHandlerError] If the server responds with a JSON-RPC error + # or a malformed result. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}) + return @server_info if connected? + + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + }) + + if response.is_a?(Hash) && response.key?("error") + clear_session + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response.is_a?(Hash) && response["result"].is_a?(Hash) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + }) + rescue StandardError + clear_session + raise + end + + @connected = true + @server_info + end + + # Returns true once `connect` has completed the full handshake + # (`initialize` response received and `notifications/initialized` sent). + # Returns false before the first handshake and after `close`. + def connected? + @connected end # Sends a JSON-RPC request and returns the parsed response body. @@ -105,7 +197,10 @@ module MCP # session state is cleared either way. # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management def close - return unless @session_id + unless @session_id + clear_session + return + end begin client.delete("", nil, session_headers) @@ -159,6 +254,8 @@ module MCP def clear_session @session_id = nil @protocol_version = nil + @server_info = nil + @connected = false end def require_faraday! diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 19917dd..5a90fde 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -633,6 +633,25 @@ module MCP assert_nil(client.session_id) end + def test_close_clears_stateless_connection_state + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + stub_notification + + client.connect + client.close + + assert_not_requested(:delete, url) + refute_predicate(client, :connected?) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + def test_close_tolerates_405_response initialize_session stub_request(:delete, url).to_return(status: 405) @@ -695,6 +714,213 @@ module MCP assert_requested(:delete, url, times: 1) end + def test_connect_performs_initialize_handshake + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" }, + body: { + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0" }, + }, + }.to_json, + ) + + notification_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 202, body: "") + + result = client.connect + + assert_requested(init_stub) + assert_requested(notification_stub) + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal({ "tools" => {} }, result["capabilities"]) + assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"]) + end + + def test_connect_caches_server_info + stub_initialize + stub_notification + + client.connect + + assert_equal("2025-11-25", client.server_info["protocolVersion"]) + end + + def test_connect_uses_default_client_info_and_protocol_version + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with do |req| + body = JSON.parse(req.body) + body["method"] == "initialize" && + body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION && + body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } && + body["params"]["capabilities"] == {} + end + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json, + ) + + client.connect + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_accepts_custom_parameters + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with do |req| + body = JSON.parse(req.body) + body["method"] == "initialize" && + body["params"]["protocolVersion"] == "2025-03-26" && + body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } && + body["params"]["capabilities"] == { "roots" => { "listChanged" => true } } + end + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-03-26" } }.to_json, + ) + + client.connect( + client_info: { name: "my-app", version: "9.9" }, + protocol_version: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + ) + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_is_idempotent + init_stub = stub_initialize + notification_stub = stub_notification + + first_result = client.connect + second_result = client.connect + + assert_same(first_result, second_result) + assert_requested(init_stub, times: 1) + assert_requested(notification_stub, times: 1) + end + + def test_connect_raises_on_jsonrpc_error_response + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, "Unsupported protocol version") + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_raises_on_missing_result + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { jsonrpc: "2.0", id: "x" }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_raises_on_unsupported_negotiated_protocol_version + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: "2099-01-01" } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, 'unsupported protocol version "2099-01-01"') + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_clears_session_when_initialized_notification_fails + stub_initialize + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 500) + + assert_raises(RequestHandlerError) do + client.connect + end + + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connected_lifecycle + refute_predicate(client, :connected?) + + stub_initialize + stub_notification + client.connect + + assert_predicate(client, :connected?) + + stub_request(:delete, url).to_return(status: 200) + client.close + + refute_predicate(client, :connected?) + end + + def test_reconnect_after_close + stub_initialize + stub_notification + client.connect + stub_request(:delete, url).to_return(status: 200) + client.close + + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.connect + + assert_predicate(client, :connected?) + assert_equal("s2", client.session_id) + end + def test_close_allows_reinitializing_a_fresh_session initialize_session stub_request(:delete, url).to_return(status: 200) @@ -732,6 +958,22 @@ module MCP client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) end + def stub_initialize + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + end + + def stub_notification + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 202, body: "") + end + def stub_request(method, url) WebMock.stub_request(method, url) end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 4142ffd..e28aedc 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -5,6 +5,77 @@ require "securerandom" module MCP class ClientTest < Minitest::Test + def test_connect_delegates_to_transport_and_forwards_keyword_args + transport = mock + init_result = { + "protocolVersion" => "2025-11-25", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "test-server", "version" => "1.0" }, + } + transport.expects(:connect).with( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ).returns(init_result).once + + result = Client.new(transport: transport).connect( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ) + + assert_equal(init_result, result) + end + + def test_connect_passes_nil_defaults_to_transport + transport = mock + transport.expects(:connect) + .with(client_info: nil, protocol_version: nil, capabilities: {}) + .returns({ "protocolVersion" => "2025-11-25" }).once + + Client.new(transport: transport).connect + end + + def test_connect_is_noop_when_transport_does_not_respond_to_connect + transport = mock + transport.stubs(:respond_to?).with(:connect).returns(false) + transport.stubs(:respond_to?).with(:server_info).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.connect) + assert_nil(client.server_info) + end + + def test_connected_delegates_to_transport_when_supported + transport = mock + transport.expects(:connected?).returns(true) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_connected_returns_true_when_transport_does_not_respond_to_connected + transport = mock + transport.stubs(:respond_to?).with(:connected?).returns(false) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_server_info_delegates_to_transport_when_supported + transport = mock + init_result = { "protocolVersion" => "2025-11-25" } + transport.expects(:server_info).returns(init_result) + + assert_equal(init_result, Client.new(transport: transport).server_info) + end + + def test_server_info_returns_nil_when_transport_does_not_expose_it + transport = mock + transport.stubs(:respond_to?).with(:server_info).returns(false) + + assert_nil(Client.new(transport: transport).server_info) + end + def test_tools_sends_request_to_transport_and_returns_tools_array transport = mock mock_response = { From 933cf94a09599e07dd68b263697b70726bb6d8ec Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sat, 2 May 2026 10:06:42 -0400 Subject: [PATCH 061/247] Update Streamable HTTP client example to use SDK client --- examples/streamable_http_client.rb | 280 +++++++++++++---------------- 1 file changed, 126 insertions(+), 154 deletions(-) diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index de83b23..7cca9ac 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -1,49 +1,25 @@ # frozen_string_literal: true +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" require "net/http" require "uri" require "json" require "logger" +require "event_stream_parser" -# Logger for client operations -logger = Logger.new($stdout) -logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" +SERVER_URL = "http://localhost:9393" + +def create_logger + logger = Logger.new($stdout) + logger.formatter = proc do |severity, datetime, _progname, msg| + "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" + end + logger end -# Server configuration -SERVER_URL = "http://localhost:9393/mcp" -PROTOCOL_VERSION = "2024-11-05" - -# Helper method to make JSON-RPC requests -def make_request(session_id, method, params = {}, id = nil) - uri = URI(SERVER_URL) - http = Net::HTTP.new(uri.host, uri.port) - - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/json" - request["Mcp-Session-Id"] = session_id if session_id - - body = { - jsonrpc: "2.0", - method: method, - params: params, - id: id || SecureRandom.uuid, - } - - request.body = body.to_json - response = http.request(request) - - { - status: response.code, - headers: response.to_hash, - body: JSON.parse(response.body), - } -rescue => e - { error: e.message } -end - -# Connect to SSE stream +# The SDK does not yet implement the optional GET SSE stream, so this example +# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream. def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -59,17 +35,13 @@ def connect_sse(session_id, logger) if response.code == "200" logger.info("SSE stream connected successfully") + parser = EventStreamParser::Parser.new response.read_body do |chunk| - chunk.split("\n").each do |line| - if line.start_with?("data: ") - data = line[6..-1] - begin - logger.info("SSE data: #{data}") - rescue JSON::ParserError - logger.debug("Non-JSON SSE data: #{data}") - end - elsif line.start_with?(": ") - logger.debug("SSE keepalive received: #{line}") + parser.feed(chunk) do |type, data, _id| + if type.empty? + logger.info("SSE event: #{data}") + else + logger.info("SSE event (#{type}): #{data}") end end end @@ -79,129 +51,129 @@ def connect_sse(session_id, logger) end end rescue Interrupt - logger.info("SSE connection interrupted by user") + logger.info("SSE connection interrupted") rescue => e logger.error("SSE connection error: #{e.message}") end -# Main client flow +def print_response(response) + if response.nil? + puts "Response accepted; watch the SSE stream for the server response." + else + puts "Response: #{JSON.pretty_generate(response)}" + end +end + def main - logger = Logger.new($stdout) - logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" - end + logger = create_logger - puts "=== MCP SSE Test Client ===" + puts <<~MESSAGE + MCP Streamable HTTP Client + Make sure the server is running (ruby examples/streamable_http_server.rb) + #{"=" * 60} + MESSAGE - # Step 1: Initialize session - logger.info("Initializing session...") + http_transport = MCP::Client::HTTP.new(url: SERVER_URL) + client = MCP::Client.new(transport: http_transport) + sse_thread = nil - init_response = make_request( - nil, - "initialize", - { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: "sse-test-client", - version: "1.0", - }, - }, - "init-1", - ) + begin + puts "=== Initializing session ===" + server_info = client.connect( + client_info: { name: "streamable-http-client", version: "1.0" }, + ) - if init_response[:error] - logger.error("Failed to initialize: #{init_response[:error]}") - exit(1) - end - - session_id = init_response[:headers]["mcp-session-id"]&.first - - if session_id.nil? - logger.error("No session ID received") - exit(1) - end - - if init_response[:body].dig("result", "capabilities", "logging") - make_request(session_id, "logging/setLevel", { level: "info" }) - end - - logger.info("Session initialized: #{session_id}") - logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}") - - # Step 2: Start SSE connection in a separate thread - sse_thread = Thread.new { connect_sse(session_id, logger) } - - # Give SSE time to connect - sleep(1) - - # Step 3: Interactive menu - loop do - puts <<~MESSAGE.chomp - - === Available Actions === - 1. Send custom notification - 2. Test echo - 3. List tools - 0. Exit - - Choose an action:#{" "} + puts <<~MESSAGE + ID: #{http_transport.session_id} + Version: #{http_transport.protocol_version} + Server: #{server_info["serverInfo"]} MESSAGE - choice = gets.chomp + unless http_transport.session_id + logger.error("No session ID received; this example requires a stateful Streamable HTTP session.") + return + end - case choice - when "1" - print("Enter notification message: ") - message = gets.chomp - print("Enter delay in seconds (0 for immediate): ") - delay = gets.chomp.to_f + puts "=== Listing tools ===" + tools = client.tools + tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } - response = make_request( - session_id, - "tools/call", - { - name: "notification_tool", - arguments: { - message: message, - delay: delay, - }, - }, - ) - if response[:body]["accepted"] - logger.info("Notification sent successfully") + echo_tool = tools.find { |tool| tool.name == "echo" } + notification_tool = tools.find { |tool| tool.name == "notification_tool" } + + sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) } + sleep(1) + + # Once the optional SSE stream is active, POST requests may receive only a + # 202 ACK while the actual JSON-RPC response is delivered over SSE. + loop do + puts <<~MENU.chomp + + === Available Actions === + 1. Send notification (triggers SSE event) + 2. Echo message + 3. Show cached tools + 0. Exit + + Choose an action:#{" "} + MENU + + case gets.chomp + when "1" + if notification_tool + print("Enter notification message: ") + message = gets.chomp + print("Enter delay in seconds (0 for immediate): ") + delay = gets.chomp.to_f + + puts "=== Calling tool: notification_tool ===" + response = client.call_tool( + tool: notification_tool, + arguments: { message: message, delay: delay }, + ) + print_response(response) + else + puts "notification_tool not available" + end + when "2" + if echo_tool + print("Enter message to echo: ") + message = gets.chomp + + puts "=== Calling tool: echo ===" + response = client.call_tool(tool: echo_tool, arguments: { message: message }) + print_response(response) + else + puts "echo tool not available" + end + when "3" + puts "=== Cached tools ===" + tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + when "0" + logger.info("Exiting...") + break else - logger.error("Error: #{response[:body]["error"]}") + puts "Invalid choice" end - when "2" - print("Enter message to echo: ") - message = gets.chomp - make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } }) - when "3" - make_request(session_id, "tools/list") - when "0" - logger.info("Exiting...") - break - else - puts "Invalid choice" + end + rescue MCP::Client::SessionExpiredError => e + logger.error("Session expired: #{e.message}") + rescue MCP::Client::RequestHandlerError => e + logger.error("Request error: #{e.message}") + rescue Interrupt + logger.info("Client interrupted") + rescue => e + logger.error("Error: #{e.message}") + logger.error(e.backtrace.first(5).join("\n")) + ensure + sse_thread.kill if sse_thread&.alive? + + if http_transport.connected? + puts "=== Closing session ===" + http_transport.close + puts "Session closed" end end - - # Clean up - sse_thread.kill if sse_thread.alive? - - # Close session - logger.info("Closing session...") - make_request(session_id, "close") - logger.info("Session closed") -rescue Interrupt - logger.info("Client interrupted by user") -rescue => e - logger.error("Client error: #{e.message}") - logger.error(e.backtrace.join("\n")) end -# Run the client -if __FILE__ == $PROGRAM_NAME - main -end +main if __FILE__ == $PROGRAM_NAME From a97e3913538aa708802fc375a44c7900b526d741 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 21 Apr 2026 01:15:42 +0900 Subject: [PATCH 062/247] Support `notifications/cancelled` per MCP specification ## Motivation and Context The MCP specification defines `notifications/cancelled` so either party can stop a previously-issued in-flight request. The Ruby SDK declared the method constant but had no receive logic, no in-flight tracking, and no way for handlers to participate in cancellation; cancellation notifications fell through to the unsupported-method path. This PR implements the **server-side** half of the spec. The server stops processing the targeted request cooperatively and suppresses its JSON-RPC response, matching the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`. Cancellation is observable by every user-overridable request handler: - `Tool.call` (tools/call) - prompt templates (prompts/get) - blocks registered via `resources_read_handler` / `completion_handler` / `resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method` Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?` or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work. Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged. On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n ested server-to-client requests (`sampling/createMessage`, `elicitation/create`); the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`, so it deliberately does not propagate nested cancellation. Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`. The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable, satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications are silently ignored per the spec. `MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR. Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation ## How Has This Been Tested? `test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract. `test/mcp/server_cancellation_test.rb` covers end-to-end cancellation: - A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled` and asserts the handler observed cancellation and the JSON-RPC response was suppressed. Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method) has its own regression test. - `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored. - The `reason` propagates into the `cancellation_reason` instrumentation field. - Custom transports implementing only the abstract `(method, params = nil)` contract keep working; the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`) are silently dropped when the transport's signature does not declare them. `StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion (so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race when a real response and a cancel arrive concurrently. ## Breaking Change None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`), and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs. The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures, and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches client-originated notifications through `ServerSession#handle_json` before returning 202; previously these were accepted without dispatch, but the existing handlers for `notifications/initialized` and `notifications/progress` were already no-ops, so no user-visible effect is expected. Adding `server_context:` to a handler block is strictly opt-in. --- README.md | 129 ++++ lib/json_rpc_handler.rb | 6 + lib/mcp.rb | 2 + lib/mcp/cancellation.rb | 72 ++ lib/mcp/cancelled_error.rb | 13 + lib/mcp/server.rb | 161 ++++- lib/mcp/server/transports/stdio_transport.rb | 7 + .../transports/streamable_http_transport.rb | 64 +- lib/mcp/server_context.rb | 13 +- lib/mcp/server_session.rb | 125 +++- test/mcp/cancellation_test.rb | 66 ++ .../streamable_http_transport_test.rb | 233 +++++++ test/mcp/server_cancellation_test.rb | 627 ++++++++++++++++++ test/mcp/server_elicitation_test.rb | 4 +- test/mcp/server_sampling_test.rb | 4 +- 15 files changed, 1485 insertions(+), 41 deletions(-) create mode 100644 lib/mcp/cancellation.rb create mode 100644 lib/mcp/cancelled_error.rb create mode 100644 test/mcp/cancellation_test.rb create mode 100644 test/mcp/server_cancellation_test.rb diff --git a/README.md b/README.md index ef70113..d180dcc 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports roots (server-to-client filesystem boundary queries) - Supports sampling (server-to-client LLM completion requests) - Supports cursor-based pagination for list operations +- Supports server-side cancellation of in-flight requests (notifications/cancelled) ### Supported Methods @@ -1096,9 +1097,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names: - `notifications/tools/list_changed` - `notifications/prompts/list_changed` - `notifications/resources/list_changed` +- `notifications/cancelled` - `notifications/progress` - `notifications/message` +### Cancellation + +The MCP Ruby SDK supports server-side handling of the +[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation). +When a client sends `notifications/cancelled` for an in-flight request, the server stops +processing cooperatively and suppresses the JSON-RPC response for that request. + +Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead, +a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools +poll it to exit early. When a tool returns after cancellation has been observed, +the server suppresses the JSON-RPC response, matching the spec. The `initialize` request +is never cancellable per the spec. + +> [!NOTE] +> Client-initiated cancellation (`Client#cancel` equivalent that would also abort +> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled` +> from the client side can be done by constructing the notification payload and writing it +> directly through the transport, but the calling thread does not yet unwind automatically. +> This is tracked as a follow-up. + +#### Server-Side: Handlers that Check for Cancellation + +Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, +`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`, +`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives +an `MCP::ServerContext` wired to the in-flight request's cancellation token. +Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise +`MCP::CancelledError` at a safe point: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that supports cancellation" + input_schema(properties: { count: { type: "integer" } }, required: ["count"]) + + def self.call(count:, server_context:) + count.times do |i| + # Exit early if the client has sent `notifications/cancelled`. + break if server_context.cancelled? + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +Alternatively, raise at the next safe point with `raise_if_cancelled!`: + +```ruby +def self.call(count:, server_context:) + count.times do |i| + server_context.raise_if_cancelled! + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) +end +``` + +When a handler observes cancellation (either by returning early with `cancelled?` or +by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and +no JSON-RPC result is sent to the client. + +The same pattern works for other handler types: + +```ruby +# resources/read +server.resources_read_handler do |params, server_context:| + server_context.raise_if_cancelled! + # read the resource +end + +# completion/complete +server.completion_handler do |params, server_context:| + server_context.raise_if_cancelled! + # compute completions +end + +# custom method +server.define_custom_method(method_name: "custom/slow") do |params, server_context:| + server_context.raise_if_cancelled! + # do work +end + +# prompts (via Prompt subclass) +class SlowPrompt < MCP::Prompt + prompt_name "slow_prompt" + + def self.template(args, server_context:) + server_context.raise_if_cancelled! + MCP::Prompt::Result.new(messages: []) + end +end +``` + +Handlers that do not declare a `server_context:` keyword continue to work unchanged - +the opt-in detection only wraps the context when the block signature asks for it. + +#### Nested Server-to-Client Requests Are Cancelled Automatically + +When a tool handler is waiting on a nested server-to-client request +(`server_context.create_sampling_message`, `create_form_elicitation`, or +`create_url_elicitation`), cancelling the parent tool call automatically raises +`MCP::CancelledError` from the nested call, so the tool does not need to wrap it +in its own `cancelled?` checks: + +```ruby +def self.call(server_context:) + result = server_context.create_sampling_message(messages: messages, max_tokens: 100) + # If the parent tools/call is cancelled while waiting above, MCP::CancelledError + # is raised here and the tool can let it propagate or clean up as needed. + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) +rescue MCP::CancelledError + # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server + # will still suppress the JSON-RPC response per the MCP spec. + raise +end +``` + +Nested cancellation propagation is supported on `StreamableHTTPTransport` only. +`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested +`server_context.create_sampling_message` inside a tool runs to completion even if +the parent `tools/call` is cancelled. The parent tool itself still observes cancellation +via `server_context.cancelled?` between nested calls. + ### Ping The MCP Ruby SDK supports the diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index d564c78..977afa3 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -18,6 +18,11 @@ module JsonRpcHandler DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/ + # Sentinel return value from a handler. When a handler returns this, + # `process_request` emits no JSON-RPC response for the request, + # matching the notification-style semantics (id is ignored). + NO_RESPONSE = Object.new.freeze + extend self def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder) @@ -103,6 +108,7 @@ module JsonRpcHandler end result = method.call(params) + return if result.equal?(NO_RESPONSE) success_response(id: id, result: result) rescue MCP::Server::RequestHandlerError => e diff --git a/lib/mcp.rb b/lib/mcp.rb index 79082c4..32b7eed 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -8,6 +8,8 @@ require_relative "mcp/version" module MCP autoload :Annotations, "mcp/annotations" + autoload :Cancellation, "mcp/cancellation" + autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" autoload :Icon, "mcp/icon" diff --git a/lib/mcp/cancellation.rb b/lib/mcp/cancellation.rb new file mode 100644 index 0000000..b702ae2 --- /dev/null +++ b/lib/mcp/cancellation.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "cancelled_error" + +module MCP + class Cancellation + attr_reader :reason, :request_id + + def initialize(request_id: nil) + @request_id = request_id + @reason = nil + @cancelled = false + @callbacks = [] + @mutex = Mutex.new + end + + def cancelled? + @mutex.synchronize { @cancelled } + end + + def cancel(reason: nil) + callbacks = @mutex.synchronize do + return false if @cancelled + + @cancelled = true + @reason = reason + @callbacks.tap { @callbacks = [] } + end + + callbacks.each do |callback| + callback.call(reason) + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" }) + end + + true + end + + # Registers a callback invoked synchronously on the first `cancel` call. + # If already cancelled, fires immediately. + # + # Returns the block itself as a handle that can be passed to `off_cancel` + # to deregister it (e.g. when a nested request completes normally and the + # hook should not fire on a later parent cancellation). + def on_cancel(&block) + fire_now = false + @mutex.synchronize do + if @cancelled + fire_now = true + else + @callbacks << block + end + end + + block.call(@reason) if fire_now + block + end + + # Removes a previously-registered `on_cancel` callback. Returns `true` + # if the callback was still pending (i.e. had not yet fired), `false` + # otherwise. Safe to call with `nil`. + def off_cancel(handle) + return false unless handle + + @mutex.synchronize { !@callbacks.delete(handle).nil? } + end + + def raise_if_cancelled! + raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled? + end + end +end diff --git a/lib/mcp/cancelled_error.rb b/lib/mcp/cancelled_error.rb new file mode 100644 index 0000000..767e6f9 --- /dev/null +++ b/lib/mcp/cancelled_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MCP + class CancelledError < StandardError + attr_reader :request_id, :reason + + def initialize(message = "Request was cancelled", request_id: nil, reason: nil) + super(message) + @request_id = request_id + @reason = reason + end + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index b687fc5..b28334a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require_relative "../json_rpc_handler" +require_relative "cancellation" +require_relative "cancelled_error" require_relative "instrumentation" require_relative "methods" require_relative "logging_message_notification" @@ -384,6 +386,13 @@ module MCP end def handle_request(request, method, session: nil, related_request_id: nil) + # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id) + # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry, + # and rescue blocks below. + if method == Methods::NOTIFICATIONS_CANCELLED + return ->(params) { handle_cancelled_notification(params, session: session) } + end + handler = @handlers[method] unless handler instrument_call("unsupported_method", server_context: { request: request }) do @@ -395,6 +404,12 @@ module MCP Methods.ensure_capability!(method, capabilities) + # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), + # so do not track it in the in-flight registry. + cancellation = if related_request_id && method != Methods::INITIALIZE + session&.register_in_flight(related_request_id) + end + ->(params) { reported_exception = nil instrument_call( @@ -406,23 +421,33 @@ module MCP when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - { contents: @handlers[Methods::RESOURCES_READ].call(params) } + { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) } when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE - @handlers[method].call(params) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) {} when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id) + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + when Methods::PROMPTS_GET + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) when Methods::COMPLETION_COMPLETE - complete(params) + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else - @handlers[method].call(params) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) end client = session&.client || @client add_instrumentation_data(client: client) if client + if cancellation&.cancelled? + add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason) + next JsonRpcHandler::NO_RESPONSE + end + result + rescue CancelledError => e + add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) + next JsonRpcHandler::NO_RESPONSE rescue RequestHandlerError => e report_exception(e.original_error || e, { request: request }) add_instrumentation_data(error: e.error_type) @@ -434,10 +459,23 @@ module MCP wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) reported_exception = wrapped raise wrapped + ensure + session&.unregister_in_flight(related_request_id) if related_request_id end } end + def handle_cancelled_notification(params, session: nil) + return unless session + return unless params.is_a?(Hash) + + request_id = params[:requestId] || params["requestId"] + return if request_id.nil? + + reason = params[:reason] || params["reason"] + session.cancel_incoming(request_id: request_id, reason: reason) + end + def default_capabilities { tools: { listChanged: true }, @@ -516,7 +554,7 @@ module MCP { tools: page[:items], nextCursor: page[:next_cursor] }.compact end - def call_tool(request, session: nil, related_request_id: nil) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) tool_name = request[:name] tool = tools[tool_name] @@ -548,8 +586,12 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id) - rescue RequestHandlerError + call_tool_with_args( + tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation + ) + rescue RequestHandlerError, CancelledError + # CancelledError is intentionally not wrapped so `handle_request` can turn it into + # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. raise rescue => e raise RequestHandlerError.new( @@ -566,7 +608,7 @@ module MCP { prompts: page[:items], nextCursor: page[:next_cursor] }.compact end - def get_prompt(request) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt @@ -579,7 +621,14 @@ module MCP prompt_args = request[:arguments] prompt.validate_arguments!(prompt_args) - call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request)) + server_context = build_server_context( + request: request, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + + call_prompt_template_with_args(prompt, prompt_args, server_context) end def list_resources(request) @@ -600,14 +649,82 @@ module MCP { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact end - def complete(params) + def complete(params, session: nil, related_request_id: nil, cancellation: nil) validate_completion_params!(params) - result = @handlers[Methods::COMPLETION_COMPLETE].call(params) + result = dispatch_optional_context_handler( + @handlers[Methods::COMPLETION_COMPLETE], + params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) normalize_completion_result(result) end + # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, + # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or + # `server_context.raise_if_cancelled!`. + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil) + dispatch_optional_context_handler( + @handlers[Methods::RESOURCES_READ], + request, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + end + + # Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`, + # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. + # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` + # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil) + return handler.call(params) unless handler_declares_server_context?(handler) + + server_context = build_server_context( + request: params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + handler.call(params, server_context: server_context) + end + + # Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter + # (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT + # treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}` + # Hash as the handler's second positional argument, which is never what the user meant. + # + # `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here, + # because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept + # that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`). + # Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked + # via `tool.call(**args, server_context: …)` without a positional argument. + def handler_declares_server_context?(handler) + return false unless handler.respond_to?(:parameters) + + handler.parameters.any? do |type, name| + name == :server_context && (type == :key || type == :keyreq) + end + end + + # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers + # (progress, cancellation, nested server-to-client requests). + def build_server_context(request:, session:, related_request_id:, cancellation:) + meta_source = request.is_a?(Hash) ? request : {} + progress_token = meta_source.dig(:_meta, :progressToken) + progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + ServerContext.new( + server_context_with_meta(meta_source), + progress: progress, + notification_target: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + end + def report_exception(exception, server_context = {}) configuration.exception_reporter.call(exception, server_context) end @@ -628,18 +745,32 @@ module MCP ).to_h end + # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. + # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument + # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`. + # Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that + # happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword, + # and a positional slot would receive the `{server_context: ctx}` Hash instead. def accepts_server_context?(method_object) parameters = method_object.parameters - parameters.any? { |type, name| type == :keyrest || name == :server_context } + parameters.any? do |type, name| + type == :keyrest || (name == :server_context && (type == :key || type == :keyreq)) + end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil) args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) - server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id) + server_context = ServerContext.new( + context, + progress: progress, + notification_target: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) tool.call(**args, server_context: server_context).to_h else tool.call(**args).to_h diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index 3d6a4ff..c96f526 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -54,6 +54,13 @@ module MCP false end + # NOTE: This signature deliberately matches the abstract `Transport#send_request` contract + # (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts. + # On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with + # explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be + # auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request + # cancellation has very limited value here regardless; servers that need cancellation propagation for nested + # server-to-client requests should use `StreamableHTTPTransport`. def send_request(method, params = nil) request_id = generate_request_id request = { jsonrpc: "2.0", id: request_id, method: method } diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 284e758..8dc8af0 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -175,7 +175,7 @@ module MCP # sends the request via SSE stream, then blocks on `queue.pop`. # When the client POSTs a response, `handle_response` matches it by `request_id` # and pushes the result onto the queue, unblocking this thread. - def send_request(method, params = nil, session_id: nil, related_request_id: nil) + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil) if @stateless raise "Stateless mode does not support server-to-client requests." end @@ -190,6 +190,7 @@ module MCP request_id = generate_request_id queue = Queue.new + cancel_hook = nil request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params @@ -229,6 +230,16 @@ module MCP raise "No active stream for #{method} request." end + if parent_cancellation && server_session + cancel_hook = parent_cancellation.on_cancel do |reason| + server_session.send_peer_cancellation( + nested_request_id: request_id, + related_request_id: related_request_id, + reason: reason, + ) + end + end + response = queue.pop if response.is_a?(Hash) && response.key?(:error) @@ -239,8 +250,18 @@ module MCP raise "SSE session closed while waiting for #{method} response." end + if response == :cancelled + reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) } + raise MCP::CancelledError.new( + "#{method} request was cancelled", + request_id: request_id, + reason: reason, + ) + end + response ensure + parent_cancellation.off_cancel(cancel_hook) if cancel_hook if request_id @mutex.synchronize do @pending_responses.delete(request_id) @@ -248,6 +269,24 @@ module MCP end end + # Unblocks a `send_request` awaiting a response when the peer is being cancelled. + # The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`. + # + # Race note: this is first-writer-wins on the pending-response queue. If a real response + # has already been pushed (client responded before the cancel hook fired), that response + # wins and `:cancelled` is enqueued behind it but never read - `send_request` returns + # the real response and deletes the pending entry in its `ensure` block. Conversely, + # if `:cancelled` arrives first, any later client response is silently dropped in `handle_response` + # because the pending entry has been removed. + def cancel_pending_request(request_id, reason: nil) + @mutex.synchronize do + if (pending = @pending_responses[request_id]) + pending[:cancel_reason] = reason + pending[:queue].push(:cancelled) + end + end + end + private def start_reaper_thread @@ -309,6 +348,7 @@ module MCP return missing_session_id_response if !@stateless && !session_id if notification?(body) + dispatch_notification(body_string, session_id) handle_accepted elsif response?(body) return session_not_found_response if !@stateless && !session_exists?(session_id) @@ -459,6 +499,22 @@ module MCP !body[:id] && !!body[:method] end + # Dispatches a client-originated notification (e.g. `notifications/cancelled`, + # `notifications/initialized`) through the server so it can update session state. + def dispatch_notification(body_string, session_id) + server_session = nil + if session_id && !@stateless + @mutex.synchronize do + session = @sessions[session_id] + server_session = session[:server_session] if session + end + end + + dispatch_handle_json(body_string, server_session) + rescue => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" }) + end + def response?(body) !!body[:id] && !body[:method] end @@ -536,6 +592,12 @@ module MCP handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) else response = dispatch_handle_json(body_string, server_session) + + # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec. + # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body, + # which would produce an empty body the client cannot parse as JSON. + return handle_accepted if response.nil? + [200, { "Content-Type" => "application/json" }, [response]] end end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 7c7b87b..3962869 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -2,11 +2,22 @@ module MCP class ServerContext - def initialize(context, progress:, notification_target:, related_request_id: nil) + attr_reader :cancellation + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id + @cancellation = cancellation + end + + def cancelled? + !!@cancellation&.cancelled? + end + + def raise_if_cancelled! + @cancellation&.raise_if_cancelled! end # Reports progress for the current tool operation. diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index de5c2d0..7e99f28 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "cancellation" require_relative "methods" module MCP @@ -15,6 +16,48 @@ module MCP @client = nil @client_capabilities = nil @logging_message_notification = nil + @in_flight = {} + @in_flight_mutex = Mutex.new + end + + # Registers a `Cancellation` token for an in-flight request. + def register_in_flight(request_id) + return if request_id.nil? + + cancellation = Cancellation.new(request_id: request_id) + @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation } + cancellation + end + + def unregister_in_flight(request_id) + return if request_id.nil? + + @in_flight_mutex.synchronize { @in_flight.delete(request_id) } + end + + def lookup_in_flight(request_id) + @in_flight_mutex.synchronize { @in_flight[request_id] } + end + + # Flips the `Cancellation` for a matching in-flight request received from the peer. + # Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5). + def cancel_incoming(request_id:, reason: nil) + cancellation = lookup_in_flight(request_id) + cancellation&.cancel(reason: reason) + end + + # Sends `notifications/cancelled` to the peer for a previously-issued request. + # Also unblocks any transport-level `send_request` waiting on a response for `request_id`. + def cancel_request(request_id:, reason: nil) + params = { requestId: request_id } + params[:reason] = reason if reason + send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params) + + if @transport.respond_to?(:cancel_pending_request) + @transport.cancel_pending_request(request_id, reason: reason) + end + rescue => e + MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id }) end def handle(request) @@ -78,6 +121,23 @@ module MCP send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) end + # Sends `notifications/cancelled` to the peer for a nested server-to-client request + # that was started inside a now-cancelled parent request. `related_request_id` + # is the parent request id so the notification is routed to the same stream + # (e.g. the parent's POST response stream on `StreamableHTTPTransport`) rather than + # the GET SSE stream. + def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil) + params = { requestId: nested_request_id } + params[:reason] = reason if reason + send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id) + + if @transport.respond_to?(:cancel_pending_request) + @transport.cancel_pending_request(nested_request_id, reason: reason) + end + rescue => e + MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id }) + end + # Sends an elicitation complete notification scoped to this session. def notify_elicitation_complete(elicitation_id:) send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id }) @@ -121,32 +181,57 @@ module MCP private - # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without - # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one. - # - # TODO: When Ruby 2.7 support is dropped, replace with a direct call: - # `@transport.send_notification(method, params, session_id: @session_id)` and - # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`. + # Forwards `send_notification` to the transport with only the kwargs the transport's method signature + # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)` + # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:` + # receive the session-scoped routing information. def send_to_transport(method, params, related_request_id: nil) - if @session_id - @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id) - else - @transport.send_notification(method, params) - end + kwargs = { + session_id: @session_id, + related_request_id: related_request_id, + }.compact + + forward_to_transport(@transport.method(:send_notification), method, params, kwargs) end - # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without - # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one. - # - # TODO: When Ruby 2.7 support is dropped, replace with a direct call: - # `@transport.send_request(method, params, session_id: @session_id)` and - # add `**` to `Transport#send_request` and `StdioTransport#send_request`. + # Forwards `send_request` to the transport with only the kwargs the transport's method signature + # actually accepts. Custom transports that implement the abstract `send_request(method, params = nil)` + # contract continue to work; bundled transports that declare `session_id:` / `related_request_id:` / + # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing. + # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up + # so that cancelling the parent also cancels this nested server-to-client request. def send_to_transport_request(method, params, related_request_id: nil) - if @session_id - @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id) + parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil + + kwargs = { + session_id: @session_id, + related_request_id: related_request_id, + parent_cancellation: parent_cancellation, + server_session: self, + }.compact + + forward_to_transport(@transport.method(:send_request), method, params, kwargs) + end + + # Calls `transport_method(method, params, **supported)` where `supported` contains only the keys + # the transport's method signature accepts. This keeps bundled transports (which declare the new kwargs) + # working while preserving compatibility with custom transports that implement only the abstract + # `(method, params = nil)` contract. + def forward_to_transport(transport_method, method, params, kwargs) + parameters = transport_method.parameters + accepts_keyrest = parameters.any? { |type, _| type == :keyrest } + supported = if accepts_keyrest + kwargs else - @transport.send_request(method, params) + allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq } + kwargs.slice(*allowed) end + + # Always splat `**supported` even when empty: on Ruby 2.7 the bare `transport_method.call(method, params)` + # form would let the trailing `params` Hash be auto-promoted to keyword arguments when the receiver + # accepts `**kwargs`, breaking handlers that rely on `params` arriving as a positional Hash. + # The explicit splat suppresses that conversion and is a no-op when `supported` is empty. + transport_method.call(method, params, **supported) end end end diff --git a/test/mcp/cancellation_test.rb b/test/mcp/cancellation_test.rb new file mode 100644 index 0000000..41449eb --- /dev/null +++ b/test/mcp/cancellation_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class CancellationTest < ActiveSupport::TestCase + test "starts not cancelled" do + cancellation = Cancellation.new + + refute_predicate cancellation, :cancelled? + assert_nil cancellation.reason + end + + test "#cancel flips state and stores reason" do + cancellation = Cancellation.new + + assert cancellation.cancel(reason: "user requested") + assert_predicate cancellation, :cancelled? + assert_equal "user requested", cancellation.reason + end + + test "#cancel is idempotent" do + cancellation = Cancellation.new + + assert cancellation.cancel(reason: "first") + refute cancellation.cancel(reason: "second") + assert_equal "first", cancellation.reason + end + + test "#on_cancel fires once on cancellation" do + cancellation = Cancellation.new + fired = [] + + cancellation.on_cancel { |reason| fired << reason } + cancellation.cancel(reason: "stop") + cancellation.cancel(reason: "again") + + assert_equal ["stop"], fired + end + + test "#on_cancel fires immediately when already cancelled" do + cancellation = Cancellation.new + cancellation.cancel(reason: "done") + fired = [] + + cancellation.on_cancel { |reason| fired << reason } + + assert_equal ["done"], fired + end + + test "#raise_if_cancelled! raises CancelledError when cancelled" do + cancellation = Cancellation.new(request_id: "req-1") + cancellation.cancel(reason: "abort") + + error = assert_raises(CancelledError) { cancellation.raise_if_cancelled! } + assert_equal "req-1", error.request_id + assert_equal "abort", error.reason + end + + test "#raise_if_cancelled! is a no-op when not cancelled" do + cancellation = Cancellation.new + + assert_nil cancellation.raise_if_cancelled! + 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 6c1983d..b2de1f4 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2168,6 +2168,239 @@ module MCP assert_equal "correct", result[:content][:text] end + test "send_request with parent_cancellation unblocks with MCP::CancelledError when cancelled" do + 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"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + cancellation = MCP::Cancellation.new(request_id: "parent-1") + server_session = @transport.instance_variable_get(:@sessions)[session_id][:server_session] + + result_queue = Queue.new + Thread.new do + @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + parent_cancellation: cancellation, + server_session: server_session, + ) + rescue => e + result_queue.push(e) + end + + sleep(0.1) + cancellation.cancel(reason: "parent cancelled") + + error = result_queue.pop + assert_kind_of MCP::CancelledError, error + end + + test "send_request deregisters on_cancel hook after completion so late parent cancel is a no-op" do + 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"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + cancellation = MCP::Cancellation.new(request_id: "parent-x") + server_session = @transport.instance_variable_get(:@sessions)[session_id][:server_session] + + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + parent_cancellation: cancellation, + server_session: server_session, + ) + result_queue.push(result) + end + + sleep(0.1) + + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + nested_request_id = request_data["id"] + + # Client responds successfully; send_request completes normally. + client_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: nested_request_id, result: { role: "assistant", content: { type: "text", text: "ok" } } }.to_json, + ) + @transport.handle_request(client_response) + + result = result_queue.pop + assert_equal "ok", result[:content][:text] + + # Snapshot what was written to the SSE stream before late cancel. + io.rewind + before_cancel = io.read + + # Parent is cancelled after the nested request already completed. The hook must + # have been deregistered, so no `notifications/cancelled` should go to the peer + # for the already-completed nested request. + cancellation.cancel(reason: "late") + + sleep(0.05) # Give any stray callback time to fire (none should). + + io.rewind + after_cancel = io.read + + refute_includes after_cancel.sub(before_cancel, ""), "notifications/cancelled" + end + + test "cancel_pending_request is no-op when a real response is already queued (race)" do + 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"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + ) + result_queue.push(result) + end + + sleep(0.1) + + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Client responded first. + client_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "ok" } } }.to_json, + ) + @transport.handle_request(client_response) + + # Cancel arrives after the response was already enqueued. Must not clobber the result. + @transport.cancel_pending_request(request_id, reason: "late cancel") + + result = result_queue.pop + assert_equal "ok", result[:content][:text] + end + + test "JSON response mode returns accepted when cancellation suppresses response" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "slow_tool") do |server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + + Tool::Response.new([{ type: "text", text: "done" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + server_session = transport.instance_variable_get(:@sessions)[session_id][:server_session] + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "slow_tool", arguments: {} }, + }.to_json, + ) + + response_queue = Queue.new + request_thread = Thread.new { response_queue.push(transport.handle_request(tool_request)) } + sleep(0.01) until server_session.lookup_in_flight("call-1") + + cancel_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "call-1", reason: "user cancelled" }, + }.to_json, + ) + + cancel_response = transport.handle_request(cancel_request) + assert_equal(202, cancel_response[0]) + + response = response_queue.pop + assert_equal(202, response[0]) + assert_empty(response[1]) + assert_empty(response[2]) + ensure + request_thread.kill if request_thread.alive? + transport.close + end + test "send_request raises on error response from client" do # Create session. init_request = create_rack_request( diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb new file mode 100644 index 0000000..b061508 --- /dev/null +++ b/test/mcp/server_cancellation_test.rb @@ -0,0 +1,627 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerCancellationTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + class MockTransport < Transport + attr_reader :requests, :notifications, :cancelled_request_ids + + def initialize(server) + super + @requests = [] + @notifications = [] + @cancelled_request_ids = [] + end + + def send_request(method, params = nil, **_kwargs) + @requests << { method: method, params: params } + {} + end + + def send_notification(method, params = nil, **_kwargs) + @notifications << { method: method, params: params } + true + end + + def cancel_pending_request(request_id, reason: nil) + @cancelled_request_ids << request_id + end + + def send_response(response); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new(name: "test_server", version: "1.0.0", configuration: configuration) + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + @session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "sess-1") + end + + test "cooperative cancellation via server_context suppresses response" do + observed_cancelled = false + + @server.define_tool(name: "slow") do |server_context:| + 20.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed_cancelled = server_context.cancelled? + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 42 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "user aborted" }, + ) + + response = call_thread.value + + assert observed_cancelled, "tool handler should observe cancellation" + assert_nil response, "cancelled request must not emit a JSON-RPC response" + end + + test "tool raising CancelledError via raise_if_cancelled! suppresses the JSON-RPC response" do + @server.define_tool(name: "raising") do |server_context:| + 50.times do + server_context.raise_if_cancelled! + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 99 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "raising", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "raised" }, + ) + + response = call_thread.value + + assert_nil response, "raise_if_cancelled! must result in no JSON-RPC response" + end + + test "CancelledError raised from nested server-to-client call suppresses response" do + @server.define_tool(name: "nesting") do |server_context:| # rubocop:disable Lint/UnusedBlockArgument + # Simulate a transport-level CancelledError coming back from a nested + # send_request (e.g. sampling/createMessage after parent was cancelled). + raise MCP::CancelledError.new(request_id: "nested-xyz", reason: "parent aborted") + end + + response = @session.handle( + jsonrpc: "2.0", + id: 123, + method: Methods::TOOLS_CALL, + params: { name: "nesting", arguments: {} }, + ) + + assert_nil response, "CancelledError propagating from a nested call must suppress the JSON-RPC response" + end + + test "cancellation with unknown request id is silently ignored" do + response = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "does-not-exist" }, + ) + + assert_nil response + end + + test "duplicate cancellation for the same in-flight request is idempotent" do + @server.define_tool(name: "slow_dup") do |server_context:| + 20.times do + break if server_context.cancelled? + + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 51 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow_dup", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + first = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "first" }, + ) + + second = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "second" }, + ) + + response = call_thread.value + + assert_nil first + assert_nil second, "duplicate cancel must not emit a response" + assert_nil response, "cancelled request must not emit a response" + end + + test "cancellation arriving after the request already completed is silently ignored" do + @server.define_tool(name: "quick") do + Tool::Response.new([{ type: "text", text: "done" }]) + end + + request_id = 61 + + response = @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "quick", arguments: {} }, + ) + + assert response, "the tool should have completed successfully before the cancel arrives" + assert_nil @session.lookup_in_flight(request_id), "completed request must be unregistered" + + late = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "late" }, + ) + + assert_nil late, "late cancel for a completed request must be silently ignored" + end + + test "initialize request cannot be cancelled" do + init_params = { + protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, + clientInfo: { name: "test", version: "1.0" }, + capabilities: {}, + } + + response = @session.handle( + jsonrpc: "2.0", + id: "init-1", + method: Methods::INITIALIZE, + params: init_params, + ) + + assert response, "initialize returns a response" + assert response[:result][:protocolVersion] + # The initialize request is not registered in the in-flight map. + assert_nil @session.lookup_in_flight("init-1") + end + + test "ServerSession#cancel_request sends notification and cancels pending" do + @session.cancel_request(request_id: "req-9", reason: "timeout") + + notification = @mock_transport.notifications.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, notification[:method] + assert_equal "req-9", notification[:params][:requestId] + assert_equal "timeout", notification[:params][:reason] + + assert_includes @mock_transport.cancelled_request_ids, "req-9" + end + + test "parent cancellation propagates to nested server-to-client requests" do + @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) + + parent_request_id = 101 + cancellation = @session.register_in_flight(parent_request_id) + + # Simulate a nested sampling/elicitation send via ServerSession. The transport + # is expected to receive `parent_cancellation:` and `server_session:` kwargs so + # it can install an `on_cancel` hook. Here we assert those kwargs are forwarded. + recorded = {} + (class << @mock_transport; self; end).define_method(:send_request) do |method, _params = nil, **kwargs| + recorded[:method] = method + recorded[:kwargs] = kwargs + {} + end + + @session.create_form_elicitation( + message: "ignored", + requested_schema: { type: "object", properties: {} }, + related_request_id: parent_request_id, + ) + + assert_equal Methods::ELICITATION_CREATE, recorded[:method] + assert_equal parent_request_id, recorded[:kwargs][:related_request_id] + assert_same cancellation, recorded[:kwargs][:parent_cancellation] + assert_same @session, recorded[:kwargs][:server_session] + end + + test "cancellation reason is recorded in instrumentation data" do + recorded_data = nil + @server.configuration.instrumentation_callback = ->(data) { recorded_data = data } + + @server.define_tool(name: "slow_with_reason") do |server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 77 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow_with_reason", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "explicit reason string" }, + ) + + call_thread.value + + assert recorded_data + assert recorded_data[:cancelled] + assert_equal "explicit reason string", recorded_data[:cancellation_reason] + end + + # Helper for the non-tools cancellation regression tests below. Spawns a background + # request, waits for the in-flight entry, sends `notifications/cancelled`, and + # returns [response, thread_result_flag_yielded_by_block]. + def drive_cancellation(request_id:, method:, params:) + result_flag = { observed: false } + call_thread = Thread.new do + @session.handle(jsonrpc: "2.0", id: request_id, method: method, params: params) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + yield(result_flag) if block_given? + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "caller aborted" }, + ) + + [call_thread.value, result_flag] + end + + test "resources/read handler that opts in to server_context observes cancellation" do + observed = false + @server.resources_read_handler do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + [{ uri: "test://resource", text: "done" }] + end + + response, = drive_cancellation( + request_id: 201, + method: Methods::RESOURCES_READ, + params: { uri: "test://resource" }, + ) + + assert observed, "resources/read handler should observe cancellation via server_context" + assert_nil response, "cancelled resources/read must not emit a JSON-RPC response" + end + + test "completion/complete handler that opts in to server_context observes cancellation" do + observed = false + @server.capabilities[:completions] = {} + @server.completion_handler do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + { completion: { values: ["v"], hasMore: false } } + end + + # completion/complete requires a known ref; register a dummy prompt. + @server.define_prompt(name: "dummy") { MCP::Prompt::Result.new(messages: []) } + + response, = drive_cancellation( + request_id: 202, + method: Methods::COMPLETION_COMPLETE, + params: { ref: { type: "ref/prompt", name: "dummy" }, argument: { name: "arg", value: "" } }, + ) + + assert observed, "completion/complete handler should observe cancellation via server_context" + assert_nil response, "cancelled completion/complete must not emit a JSON-RPC response" + end + + test "prompts/get template that opts in to server_context observes cancellation" do + observed = false + prompt_class = Class.new(MCP::Prompt) do + prompt_name "slow_prompt" + + define_singleton_method(:template) do |_args, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + MCP::Prompt::Result.new(messages: []) + end + end + @server.prompts[prompt_class.name_value] = prompt_class + # Share `observed` between the closure above and the test scope. + @server.singleton_class.define_method(:_test_observed) { observed } + + response, = drive_cancellation( + request_id: 203, + method: Methods::PROMPTS_GET, + params: { name: "slow_prompt" }, + ) + + assert observed, "prompt template should observe cancellation via server_context" + assert_nil response, "cancelled prompts/get must not emit a JSON-RPC response" + end + + test "send_to_transport (notifications) works with a custom transport that only implements the abstract signature" do + # Regression: `ServerSession#send_to_transport` must not assume the + # transport's `send_notification` accepts the new kwargs + # (`session_id:` / `related_request_id:`). Custom transports that + # implement the abstract `Transport#send_notification(method, params = nil)` + # contract must keep working - this is exercised via `cancel_request` / + # `send_peer_cancellation` which the cancellation feature relies on. + minimal_transport = Class.new(Transport) do + attr_reader :recorded + + def initialize(server) + super + @recorded = [] + end + + def send_notification(method, params = nil) + @recorded << [method, params] + true + end + + def send_request(method, params = nil) + @recorded << [method, params] + {} + end + + def send_response(_); end + def open; end + def close; end + end.new(@server) + + session = ServerSession.new(server: @server, transport: minimal_transport) + + assert_nothing_raised do + session.cancel_request(request_id: "req-1", reason: "timeout") + end + + recorded = minimal_transport.recorded.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, recorded[0] + assert_equal "req-1", recorded[1][:requestId] + assert_equal "timeout", recorded[1][:reason] + end + + test "send_to_transport_request works with a custom transport that only implements the abstract signature" do + # Regression: `ServerSession#send_to_transport_request` must not assume + # the transport's `send_request` accepts the new kwargs + # (`session_id:` / `related_request_id:` / `parent_cancellation:` / + # `server_session:`). Custom transports that implement the abstract + # `Transport#send_request(method, params = nil)` contract must keep working. + minimal_transport = Class.new(Transport) do + attr_reader :recorded + + def initialize(server) + super + @recorded = [] + end + + def send_request(method, params = nil) + @recorded << [method, params] + {} # fake response + end + + def send_notification(method, params = nil) + @recorded << [method, params] + true + end + + def send_response(_); end + def open; end + def close; end + end.new(@server) + + session = ServerSession.new(server: @server, transport: minimal_transport) + session.instance_variable_set(:@client_capabilities, { sampling: {} }) + + assert_nothing_raised do + session.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "hi" } }], + max_tokens: 10, + ) + end + + recorded = minimal_transport.recorded.last + assert_equal Methods::SAMPLING_CREATE_MESSAGE, recorded[0] + refute_nil recorded[1] + end + + test "tool with positional `server_context` parameter is not auto-opted-in" do + # Regression: `accepts_server_context?` (used by tool/prompt dispatch) must + # require `server_context` as a *keyword* parameter. A tool whose `call` + # signature is `def self.call(arg, server_context)` (positional name collision) + # would previously have been opt-in, and `tool.call(**args, server_context: ctx)` + # would have blown up or passed the wrapped Hash to the wrong slot. + tool = Class.new(MCP::Tool) do + tool_name "positional_collision" + input_schema(properties: { a: { type: "string" } }, required: ["a"]) + + class << self + def call(a, server_context) + [a, server_context] + end + end + end + @server.tools[tool.name_value] = tool + + # Should dispatch without `server_context:` kwarg - the positional arg collision + # must NOT trigger opt-in. The tool receives only its declared positional (a). + response = @session.handle( + jsonrpc: "2.0", + id: 303, + method: Methods::TOOLS_CALL, + params: { name: "positional_collision", arguments: { a: "hello" } }, + ) + + # Because the tool signature isn't opt-in, `tool.call(**args)` is called + # (without `server_context:`). The method has `server_context` as a required + # positional, so missing it raises ArgumentError - caught as an internal error. + # The key assertion is that we do NOT pass `server_context:` kwarg that would + # flow into the positional slot as a Hash. + assert response.dig(:error), "dispatch should not silently inject server_context kwarg" + end + + test "handler with positional `server_context` parameter is not auto-opted-in" do + # Regression: `handler_declares_server_context?` must require `server_context` + # as a *keyword* parameter. A positional parameter that happens to be named + # `server_context` (rare but possible) would previously have been treated as + # opt-in, and the dispatch site would call `handler.call(params, server_context: ctx)` + # - the second positional argument would become the Hash `{server_context: ctx}`, + # which is never what the user intended. + received_args = nil + @server.define_custom_method(method_name: "custom/positional_name") do |params, server_context| + received_args = [params, server_context] + { ok: true } + end + + response = @session.handle( + jsonrpc: "2.0", + id: 302, + method: "custom/positional_name", + params: { hello: "world" }, + ) + + assert_equal [{ hello: "world" }, nil], + received_args, + "positional `server_context` must receive the single `params` argument, not an auto-wrapped context" + assert_equal({ ok: true }, response[:result]) + end + + test "handler with **kwargs-only signature is not auto-opted-in to server_context" do + # Regression: a block like `|**opts|` would have triggered opt-in under the + # looser `accepts_server_context?` check used by tools, and the dispatch site + # would then call `handler.call(params, server_context:)` - but a proc that + # only declares `**opts` cannot accept the positional `params` (lambdas raise + # ArgumentError, non-lambda procs silently drop it). The stricter + # `handler_declares_server_context?` check requires `server_context` as a + # named keyword to opt in, so a kwargs-only handler receives only `params`. + received_positional = nil + @server.define_custom_method(method_name: "custom/kwargs_only") do |params, **_opts| + received_positional = params + { ok: true } + end + + response = @session.handle( + jsonrpc: "2.0", + id: 301, + method: "custom/kwargs_only", + params: { hello: "world" }, + ) + + assert_equal({ hello: "world" }, received_positional) + assert_equal({ ok: true }, response[:result]) + end + + test "custom method handler that opts in to server_context observes cancellation" do + observed = false + @server.define_custom_method(method_name: "custom/slow") do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + { ok: true } + end + + response, = drive_cancellation( + request_id: 204, + method: "custom/slow", + params: {}, + ) + + assert observed, "custom method handler should observe cancellation via server_context" + assert_nil response, "cancelled custom method must not emit a JSON-RPC response" + end + + test "send_peer_cancellation routes notification on parent stream via related_request_id" do + recorded_notifications = [] + (class << @mock_transport; self; end).define_method(:send_notification) do |method, params = nil, **kwargs| + recorded_notifications << { method: method, params: params, kwargs: kwargs } + true + end + + @session.send_peer_cancellation( + nested_request_id: "nested-1", + related_request_id: 42, + reason: "parent aborted", + ) + + notif = recorded_notifications.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, notif[:method] + assert_equal "nested-1", notif[:params][:requestId] + assert_equal "parent aborted", notif[:params][:reason] + # Crucially, the cancel notification targets the parent's stream, not the GET SSE stream. + assert_equal 42, notif[:kwargs][:related_request_id] + end + end +end diff --git a/test/mcp/server_elicitation_test.rb b/test/mcp/server_elicitation_test.rb index 82dd414..66a70f4 100644 --- a/test/mcp/server_elicitation_test.rb +++ b/test/mcp/server_elicitation_test.rb @@ -15,14 +15,14 @@ module MCP @notifications = [] end - def send_request(method, params = nil) + def send_request(method, params = nil, **_kwargs) @requests << { method: method, params: params } { action: "accept" } end def send_response(response); end - def send_notification(method, params = nil) + def send_notification(method, params = nil, **_kwargs) @notifications << { method: method, params: params } end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index ffb3539..dd12356 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -14,7 +14,7 @@ module MCP @requests = [] end - def send_request(method, params = nil) + def send_request(method, params = nil, **_kwargs) @requests << { method: method, params: params } { role: "assistant", @@ -25,7 +25,7 @@ module MCP end def send_response(response); end - def send_notification(method, params = nil); end + def send_notification(method, params = nil, **_kwargs); end def open; end def close; end end From 704d362a1224a403ea2a468dde311c54b280a379 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 3 May 2026 15:51:32 +0900 Subject: [PATCH 063/247] Remove useless condition in example ## Motivation and Context The example files `examples/http_client.rb` and `examples/streamable_http_client.rb` are intended to be run as standalone scripts and are not designed to be reused as modules (e.g., via `require`). The `if __FILE__ == $PROGRAM_NAME` guard therefore serves no purpose and is removed. ## How Has This Been Tested? Manually verified that each example script can still be executed directly (`ruby examples/http_client.rb`, `ruby examples/streamable_http_client.rb`). No automated tests cover files under `examples/`. ## Breaking Changes None. The files under `examples/` are standalone scripts and are not part of the gem's public API. --- examples/http_client.rb | 5 +++-- examples/streamable_http_client.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/http_client.rb b/examples/http_client.rb index 3efbff9..4ef95c7 100644 --- a/examples/http_client.rb +++ b/examples/http_client.rb @@ -137,8 +137,7 @@ class MCPHTTPClient end end -# Main script -if __FILE__ == $PROGRAM_NAME +def main puts <<~MESSAGE MCP HTTP Client Example Make sure the HTTP server is running (ruby examples/http_server.rb) @@ -182,3 +181,5 @@ if __FILE__ == $PROGRAM_NAME client.close_session end end + +main diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index 7cca9ac..79e3f38 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -176,4 +176,4 @@ def main end end -main if __FILE__ == $PROGRAM_NAME +main From 46b738e945b775bacd4d69d54a70a476ff77e185 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 3 May 2026 02:43:19 +0900 Subject: [PATCH 064/247] Add client-level connect handshake to stdio transport ## Motivation and Context This commit implements Phase 1 of 3 for #334. The MCP specification requires an `initialize` request followed by a `notifications/initialized` notification before any other interaction. PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect` to expose this handshake explicitly for the HTTP transport, but stdio remained on its private `initialize_session` lazy-init path, inconsistent with the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`), both of which require an explicit handshake call regardless of transport. The implicit init path on the first `send_request` is preserved as a non-breaking compatibility shim. Public API added on `Stdio`: - `connect(client_info:, protocol_version:, capabilities:)` performs the handshake, validates the negotiated `protocolVersion` against `MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`, caches the `InitializeResult`, and is idempotent. - `connected?` reports handshake completion. - `server_info` exposes the cached `InitializeResult`. Documentation and examples are updated so new users learn the explicit pattern from the start. ## How Has This Been Tested? Fifteen regression tests cover: explicit handshake, `server_info` caching, idempotence, custom keyword arguments, JSON-RPC error response, missing or non-Hash `result`, unsupported protocol version with state rollback, state rollback when the `notifications/initialized` write fails, retry after a failed handshake, and the `connected?` / `server_info` lifecycle. The pre-existing stdio tests pass without modification, verifying that the implicit-init compatibility path still works. ## Breaking Changes None for the typical "implicit init via `client.tools` / `client.call_tool`" usage. Existing stdio clients keep working unchanged. Three observable behavior changes for code that inspects transport state directly: - `Client#connected?` for a never-initialized stdio client returns `false` (previously fell through to a `true` default via the `respond_to?` fallback). - `Client#server_info` for stdio returns the cached `InitializeResult` after the handshake (previously always `nil` via the fallback). - The implicit handshake path now validates the server's negotiated protocol version, raising `RequestHandlerError` for unsupported versions. Production servers that return one of the supported versions are unaffected; this closes a pre-existing spec-conformance gap on stdio. --- README.md | 6 + docs/building-clients.md | 31 +-- examples/README.md | 2 +- examples/stdio_client.rb | 3 + lib/mcp/client.rb | 20 +- lib/mcp/client/stdio.rb | 149 ++++++++---- test/mcp/client/stdio_test.rb | 445 ++++++++++++++++++++++++++++++++++ 7 files changed, 581 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index d180dcc..71fb5ac 100644 --- a/README.md +++ b/README.md @@ -1782,6 +1782,9 @@ stdio_transport = MCP::Client::Stdio.new( ) client = MCP::Client.new(transport: stdio_transport) +# Perform the MCP initialization handshake before sending any requests. +client.connect + # List available tools. tools = client.tools tools.each do |tool| @@ -1822,6 +1825,9 @@ Example usage: http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") client = MCP::Client.new(transport: http_transport) +# Perform the MCP initialization handshake before sending any requests. +client.connect + # List available tools tools = client.tools tools.each do |tool| diff --git a/docs/building-clients.md b/docs/building-clients.md index 1fa817a..247ec75 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -16,6 +16,22 @@ The `MCP::Client` class provides an interface for interacting with MCP servers. - Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`) - Completion requests (`MCP::Client#complete`) +## Handshake + +Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +client.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +client.connected? # => true +client.server_info # => cached InitializeResult +``` + +`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. + +This applies to both the Stdio and HTTP transports below. + ## Stdio Transport Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses: @@ -28,6 +44,7 @@ stdio_transport = MCP::Client::Stdio.new( read_timeout: 30 ) client = MCP::Client.new(transport: stdio_transport) +client.connect tools = client.tools tools.each do |tool| @@ -75,20 +92,6 @@ response = client.call_tool( ) ``` -### Handshake - -Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): - -```ruby -client.connect -# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } - -client.connected? # => true -client.server_info # => cached InitializeResult -``` - -`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. - ### Sessions After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: diff --git a/examples/README.md b/examples/README.md index 71700e1..c0fcb33 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,10 +28,10 @@ $ ruby examples/stdio_client.rb The client will automatically launch `stdio_server.rb` as a subprocess and demonstrate: +- Performing the MCP initialization handshake via `client.connect` - Listing and calling tools - Listing prompts - Listing and reading resources -- Automatic MCP protocol initialization - Transport cleanup on exit ### 3. HTTP Server (`http_server.rb`) diff --git a/examples/stdio_client.rb b/examples/stdio_client.rb index 8bcb5e0..60b2067 100644 --- a/examples/stdio_client.rb +++ b/examples/stdio_client.rb @@ -13,6 +13,9 @@ transport = MCP::Client::Stdio.new(command: "ruby", args: [server_script]) client = MCP::Client.new(transport: transport) begin + # Perform the MCP initialization handshake before sending any requests. + client.connect + # List available tools puts "=== Listing tools ===" tools = client.tools diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 7cf5983..b651671 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -61,18 +61,17 @@ module MCP # The server's `InitializeResult` (protocol version, capabilities, server info, # instructions), as reported by the transport after a successful `connect`. - # Returns `nil` before `connect`, after `close`, or when the transport manages - # the handshake implicitly and does not expose it (e.g. stdio). + # Returns `nil` before `connect`, after `close`, or when the transport does + # not expose a cached handshake result. def server_info transport.server_info if transport.respond_to?(:server_info) end - # Performs the MCP `initialize` handshake by delegating to the transport when - # it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the - # server's `InitializeResult`. + # Performs the MCP `initialize` handshake by delegating to the transport + # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's + # `InitializeResult`. # - # When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio` - # manages the handshake implicitly on the first request), this is a no-op and + # When the transport does not respond to `:connect`, this is a no-op and # returns `nil`. # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. @@ -91,10 +90,9 @@ module MCP ) end - # Returns true once `connect` has completed the handshake on transports that - # expose connection state. Transports that manage the handshake implicitly - # (e.g. stdio) always report `true`, since the first request will initialize - # on demand. + # Returns true once `connect` has completed the handshake on the underlying + # transport. Transports that do not expose connection state are assumed + # connected and return `true`. def connected? return transport.connected? if transport.respond_to?(:connected?) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index db4e726..9ddabc8 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -19,7 +19,7 @@ module MCP CLOSE_TIMEOUT = 2 STDERR_READ_SIZE = 4096 - attr_reader :command, :args, :env + attr_reader :command, :args, :env, :server_info def initialize(command:, args: [], env: nil, read_timeout: nil) @command = command @@ -33,11 +33,108 @@ module MCP @stderr_thread = nil @started = false @initialized = false + @server_info = nil + end + + # Performs the MCP `initialize` handshake: sends an `initialize` request + # followed by the required `notifications/initialized` notification. The + # server's `InitializeResult` (protocol version, capabilities, server + # info, instructions) is cached on the transport and returned. + # + # Idempotent: a second call returns the cached `InitializeResult` without + # contacting the server. After `close`, state is cleared and `connect` + # will handshake again. Spawns the subprocess via `start` if it has not + # been started yet. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. + # @param protocol_version [String, nil] Protocol version to offer. Defaults + # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. + # @return [Hash] The server's `InitializeResult`. + # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, + # a malformed result, or an unsupported protocol version. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}) + return @server_info if @initialized + + start unless @started + + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + + init_request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + } + + write_message(init_request) + response = read_response(init_request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response["result"].is_a?(Hash) + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # Per spec, if the client does not support the server's returned protocol version, + # the client SHOULD disconnect. Roll back the cached `InitializeResult` before + # raising so a retry starts without a stale `server_info`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation + @server_info = nil + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + } + write_message(notification) + rescue StandardError + @server_info = nil + raise + end + + @initialized = true + @server_info + end + + # Returns true once `connect` (or the implicit handshake on the first + # `send_request`) has completed. Returns false before the handshake + # and after `close`. + def connected? + @initialized end def send_request(request:) start unless @started - initialize_session unless @initialized + connect unless @initialized write_message(request) read_response(request) @@ -98,57 +195,11 @@ module MCP @stderr_thread.join(CLOSE_TIMEOUT) @started = false @initialized = false + @server_info = nil end private - # The client MUST send a protocol version it supports. This SHOULD be the latest version. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation - # - # Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs: - # https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175 - # https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495 - def initialize_session - init_request = { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::INITIALIZE, - params: { - protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION }, - }, - } - - write_message(init_request) - response = read_response(init_request) - - if response.key?("error") - error = response["error"] - raise RequestHandlerError.new( - "Server initialization failed: #{error["message"]}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - unless response.key?("result") - raise RequestHandlerError.new( - "Server initialization failed: missing result in response", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - notification = { - jsonrpc: JsonRpcHandler::Version::V2_0, - method: MCP::Methods::NOTIFICATIONS_INITIALIZED, - } - write_message(notification) - - @initialized = true - end - def write_message(message) ensure_running! json = JSON.generate(message) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index b177f6a..4f09abf 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -722,8 +722,453 @@ module MCP stdout_write.close end + def test_connect_performs_initialize_handshake_explicitly + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + received_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + + notification_line = stdin_read.gets + received_methods << JSON.parse(notification_line)["method"] + end + + result = transport.connect + + server_thread.join + + assert_equal(["initialize", "notifications/initialized"], received_methods) + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal({ "tools" => {} }, result["capabilities"]) + assert_equal({ "name" => "test-server", "version" => "1.0.0" }, result["serverInfo"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_caches_server_info + transport, server_thread, pipes = stub_successful_connect + + transport.connect + + assert_equal("2025-11-25", transport.server_info["protocolVersion"]) + assert_equal({ "tools" => {} }, transport.server_info["capabilities"]) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_is_idempotent + transport, server_thread, pipes = stub_successful_connect + + first_result = transport.connect + second_result = transport.connect + + assert_same(first_result, second_result) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_accepts_custom_parameters + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_init_params = nil + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_init_params = init_request["params"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-03-26" }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect( + client_info: { name: "my-app", version: "9.9" }, + protocol_version: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + ) + + assert_equal("2025-03-26", sent_init_params["protocolVersion"]) + assert_equal({ "name" => "my-app", "version" => "9.9" }, sent_init_params["clientInfo"]) + assert_equal({ "roots" => { "listChanged" => true } }, sent_init_params["capabilities"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_jsonrpc_error_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + error: { code: -32602, message: "boom" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "boom") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_missing_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_non_hash_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: [], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_clears_state_when_initialized_notification_fails + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + # Close stdin_read first so the next write to @stdin (the notification) raises EPIPE. + stdin_read.close + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + [stdin_read, stdin_write, stdout_read, stdout_write].each do |io| + io.close unless io.closed? + end + end + + def test_connect_can_be_retried_after_failed_handshake + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + # First handshake: server returns an unsupported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + + # Second handshake: server returns a supported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + stdin_read.gets + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + + result = transport.connect + + server_thread.join + + assert_predicate(transport, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_unsupported_protocol_version + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "unsupported protocol version") + assert_includes(error.message, "1999-01-01") + assert_equal(["initialize"], sent_methods) + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connected_is_false_before_first_send_request + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + refute_predicate(transport, :connected?) + end + + def test_connected_is_true_after_explicit_connect + transport, server_thread, pipes = stub_successful_connect + + refute_predicate(transport, :connected?) + + transport.connect + + assert_predicate(transport, :connected?) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connected_is_false_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + + assert_predicate(transport, :connected?) + + transport.close + + refute_predicate(transport, :connected?) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + + def test_server_info_is_nil_before_connect + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + assert_nil(transport.server_info) + end + + def test_server_info_is_cleared_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + transport.instance_variable_set(:@server_info, { "protocolVersion" => "2025-11-25" }) + + transport.close + + assert_nil(transport.server_info) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + private + def stub_successful_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + stdin_read.gets + end + + [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] + end + def mock_wait_thread thread = mock("wait_thread") thread.stubs(:alive?).returns(true) From 32b6c9b64685480e00d041ef7fb24a2167053846 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 5 May 2026 08:13:28 +0900 Subject: [PATCH 065/247] Release 0.15.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. It would be best to make the usage from #336 available sooner rather than later. --- CHANGELOG.md | 16 ++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9baa7..e844c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0] - 2026-05-05 + +### Added + +- Support `notifications/cancelled` per MCP specification (#332) +- Add client-level `connect` for initialize handshake (#327) +- Add client-level `connect` handshake to stdio transport (#336) + +### Changed + +- Return tool argument validation failures as tool execution errors (#333) + +### Removed + +- Remove obsolete `MCP::Transports` module (#331) + ## [0.14.0] - 2026-04-24 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 885588f..696262e 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.14.0" + VERSION = "0.15.0" end From 9603b2fe934c05ff56f98f27cd4448ac1437974f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 13 Mar 2026 03:33:13 +0900 Subject: [PATCH 066/247] Support running MCP client conformance tests against the Ruby SDK ## Motivation and Context PR #248 introduced server conformance testing using `@modelcontextprotocol/conformance`. This extends coverage to the client side so that `MCP::Client` behavior can also be validated against the MCP specification. ### Scope Client conformance is driven by the conformance runner, which spawns test servers for each scenario and invokes `conformance/client.rb` against them. The client script delegates the MCP initialize handshake to `MCP::Client#connect` and executes scenario-specific operations (for example listing tools or calling a tool). ### Usage Run all conformance tests (server + client): ```console bundle exec rake conformance ``` Run a single client scenario: ```console bundle exec rake conformance SCENARIO=initialize ``` List available scenarios: ```console bundle exec rake conformance_list ``` ## How Has This Been Tested? Ran `bundle exec rake conformance` locally. - Server conformance: 40 passed, 0 failed. - Client conformance: 3 passed, 54 failed (all 54 listed in `expected_failures.yml`). ## Breaking Change No. This change only adds test infrastructure under `conformance/` and does not modify SDK behavior. --- Rakefile | 7 ++- conformance/README.md | 28 ++++++------ conformance/client.rb | 34 +++++++++++++++ conformance/client_runner.rb | 48 +++++++++++++++++++++ conformance/expected_failures.yml | 27 ++++++++++++ conformance/{runner.rb => server_runner.rb} | 4 +- 6 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 conformance/client.rb create mode 100644 conformance/client_runner.rb rename conformance/{runner.rb => server_runner.rb} (96%) diff --git a/Rakefile b/Rakefile index 6904e57..62e92d5 100644 --- a/Rakefile +++ b/Rakefile @@ -20,7 +20,8 @@ desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" task :conformance do |t| next unless npx_available?(t.name) - require_relative "conformance/runner" + require_relative "conformance/server_runner" + require_relative "conformance/client_runner" options = {} options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] @@ -28,7 +29,8 @@ task :conformance do |t| options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] options[:verbose] = true if ENV["VERBOSE"] - Conformance::Runner.new(**options).run + Conformance::ServerRunner.new(**options).run + Conformance::ClientRunner.new(**options.except(:port)).run end desc "List available conformance scenarios" @@ -36,6 +38,7 @@ task :conformance_list do |t| next unless npx_available?(t.name) system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") end desc "Start the conformance server (PORT)" diff --git a/conformance/README.md b/conformance/README.md index 159def0..5179012 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -15,22 +15,22 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont bundle exec rake conformance ``` -Starts the conformance server, runs all active scenarios against it, prints a pass/fail -summary for each scenario, and exits with a non-zero status code if any unexpected failures -are detected. Scenarios listed in `expected_failures.yml` are allowed to fail without -affecting the exit code. +Runs both server and client conformance tests in sequence. Server conformance starts the +conformance server and tests it. Client conformance spawns test servers for each scenario +and invokes `conformance/client.rb` against them. Scenarios listed in `expected_failures.yml` +are allowed to fail without affecting the exit code. ### Environment variables -| Variable | Description | Default | -|----------------|--------------------------------------|---------| -| `PORT` | Server port | `9292` | -| `SCENARIO` | Run a single scenario by name | (all) | -| `SPEC_VERSION` | Filter scenarios by spec version | (all) | -| `VERBOSE` | Show raw JSON output when set | (off) | +| Variable | Description | Default | +|----------------|---------------------------------------|---------| +| `PORT` | Server port (server conformance only) | `9292` | +| `SCENARIO` | Run a single scenario by name | (all) | +| `SPEC_VERSION` | Filter scenarios by spec version | (all) | +| `VERBOSE` | Show raw JSON output when set | (off) | ```bash -# Run a single scenario +# Run a single server scenario bundle exec rake conformance SCENARIO=ping # Use a different port with verbose output @@ -91,8 +91,10 @@ submissions. ``` conformance/ server.rb # Conformance server (Rack + Puma, default port 9292) - runner.rb # Starts the server, runs npx conformance, exits with result code - expected_failures.yml # Baseline of known-failing scenarios + server_runner.rb # Starts the server, runs npx conformance server, exits with result code + client.rb # Conformance client (invoked by npx conformance client) + client_runner.rb # Runs npx conformance client, exits with result code + expected_failures.yml # Baseline of known-failing scenarios (server and client) README.md # This file ``` diff --git a/conformance/client.rb b/conformance/client.rb new file mode 100644 index 0000000..5d8ba11 --- /dev/null +++ b/conformance/client.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Conformance test client for the MCP Ruby SDK. +# Invoked by the conformance runner: +# MCP_CONFORMANCE_SCENARIO= bundle exec ruby conformance/client.rb +# +# The server URL is passed as the last positional argument. +# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, +# which is set automatically by the conformance test runner. + +require_relative "../lib/mcp" + +scenario = ENV["MCP_CONFORMANCE_SCENARIO"] +server_url = ARGV.last + +unless scenario && server_url + abort("Usage: MCP_CONFORMANCE_SCENARIO= ruby conformance/client.rb ") +end + +transport = MCP::Client::HTTP.new(url: server_url) +client = MCP::Client.new(transport: transport) +client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) + +case scenario +when "initialize" + client.tools +when "tools_call" + tools = client.tools + add_numbers = tools.find { |t| t.name == "add_numbers" } + abort("Tool add_numbers not found") unless add_numbers + client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) +else + abort("Unknown or unsupported scenario: #{scenario}") +end diff --git a/conformance/client_runner.rb b/conformance/client_runner.rb new file mode 100644 index 0000000..a99fbac --- /dev/null +++ b/conformance/client_runner.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Runs `npx @modelcontextprotocol/conformance client` against the conformance client script. +require "English" + +module Conformance + class ClientRunner + def initialize(scenario: nil, spec_version: nil, verbose: false) + @scenario = scenario + @spec_version = spec_version + @verbose = verbose + end + + def run + command = build_command + puts "Command: #{command.join(" ")}\n\n" + + system(*command) + conformance_exit_code = $CHILD_STATUS.exitstatus + exit(conformance_exit_code || 1) unless conformance_exit_code == 0 + end + + private + + def build_command + expected_failures_yml = File.expand_path("expected_failures.yml", __dir__) + client_script = File.expand_path("client.rb", __dir__) + + npx_command = [ + "npx", + "--yes", + "@modelcontextprotocol/conformance", + "client", + "--command", + "bundle exec ruby #{client_script}", + ] + npx_command += if @scenario + ["--scenario", @scenario] + else + ["--suite", "all"] + end + npx_command += ["--spec-version", @spec_version] if @spec_version + npx_command += ["--verbose"] if @verbose + npx_command += ["--expected-failures", expected_failures_yml] + npx_command + end + end +end diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 03939d5..080ef91 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1 +1,28 @@ server: [] +client: + # TODO: SSE reconnection not implemented in Ruby client. + - sse-retry + # TODO: Elicitation not implemented in Ruby client. + - elicitation-sep1034-client-defaults + # TODO: OAuth/auth not implemented in Ruby client. + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + - auth/2025-03-26-oauth-metadata-backcompat + - auth/2025-03-26-oauth-endpoint-fallback + - auth/client-credentials-jwt + - auth/client-credentials-basic + - auth/cross-app-access-complete-flow + - auth/offline-access-scope + - auth/offline-access-not-supported diff --git a/conformance/runner.rb b/conformance/server_runner.rb similarity index 96% rename from conformance/runner.rb rename to conformance/server_runner.rb index 033a3bd..0a4e9e2 100644 --- a/conformance/runner.rb +++ b/conformance/server_runner.rb @@ -6,7 +6,7 @@ require "net/http" require_relative "server" module Conformance - class Runner + class ServerRunner # Timeout for waiting for the Puma server to start. SERVER_START_TIMEOUT = 20 SERVER_POLL_INTERVAL = 0.5 @@ -83,7 +83,7 @@ module Conformance terminate_server(server_pid) end - exit(conformance_exit_code || 1) + exit(conformance_exit_code || 1) unless conformance_exit_code == 0 end def terminate_server(pid) From 25f3f9a33ad632cccf88adb59646d94073c4e98a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 9 May 2026 19:12:37 +0900 Subject: [PATCH 067/247] Use `Server#tools` instead of `instance_variable_get` Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/pull/301#discussion_r3046699277. `Server#tools` method can be used instead of a hack like `instance_variable_get(:@tools)`. --- test/mcp/server_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index b2e0b2d..a8f3ea0 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1390,7 +1390,7 @@ module MCP Tool::Response.new({ "response" => message }) end - stored_tool = @server.instance_variable_get(:@tools)["defined_tool"] + stored_tool = @server.tools["defined_tool"] assert_not_nil(stored_tool) assert_equal(MCP::Tool::OutputSchema.new({ type: "object", properties: { response: { type: "string" } }, required: ["response"] }), stored_tool.output_schema) From 1fc698bb8e6af313674c132eb9383458fdcbd907 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 1 May 2026 02:17:31 +0900 Subject: [PATCH 068/247] Advertise JSON Schema 2020-12 dialect on emitted tool schemas ## Motivation and Context MCP 2025-11-25 (SEP-1613) establishes JSON Schema 2020-12 as the default dialect for MCP schema definitions. https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1613 The Ruby SDK was emitting tool input and output schemas with no `$schema` field, leaving the dialect implicit and inconsistent with the TypeScript SDK (`packages/core/src/util/standardSchema.ts`, which emits with `target: 'draft-2020-12'`) and the Python SDK (which preserves `$schema` in its types and conformance fixtures). `Tool::Schema#to_h` now includes `"$schema": "https://json-schema.org/draft/2020-12/schema"` unless the user supplied a different `$schema` value, so clients see the canonical dialect URI on the wire. Runtime metaschema and argument validation remain on draft-04 because the `json-schema` gem does not yet support 2020-12. Switching the runtime validator is left as a separate change because it requires either replacing the gem or waiting for upstream 2020-12 support. ## How Has This Been Tested? Updated `Tool::Schema#to_h` expectations across `input_schema_test`, `output_schema_test`, `tool_test`, and the `tools/list` server tests to reflect the new `$schema` field. Added focused regression tests: - `to_h` preserves a user-supplied `$schema` URI rather than overwriting it (covered for both symbol-keyed input and the string-keyed Hash form that goes through the initializer's `symbolize_names` normalization) - `validate_arguments` / `validate_result` continue to work when the user supplies a 2020-12 `$schema` URI on the schema (the validator strips the unknown top-level dialect URI before invoking the draft-04 validator, instead of raising `JSON::Schema::SchemaError`) - `tools/list` JSON-RPC response includes the 2020-12 `$schema` on both `inputSchema` and `outputSchema` (transport-boundary assertion) `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes Additive change. Tolerant consumers of the `tools/list` payload are unaffected. Consumers that strictly assert hash equality on the emitted `inputSchema` / `outputSchema` need to account for the new `$schema` key, but no schema semantics change. Schemas using 2020-12-only keywords (e.g. `prefixItems`, nested `$schema` on subschemas) are still rejected by the draft-04 metaschema validator, as before. Lifting that restriction depends on adopting a 2020-12-capable validator and is out of scope for this change. --- lib/mcp/tool/schema.rb | 26 ++++++++++-- test/mcp/server_test.rb | 22 +++++++++- test/mcp/tool/input_schema_test.rb | 60 ++++++++++++++++++++++++-- test/mcp/tool/output_schema_test.rb | 45 ++++++++++++++++++-- test/mcp/tool_test.rb | 65 ++++++++++++++++++++++++----- 5 files changed, 196 insertions(+), 22 deletions(-) diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb index de6d4d7..98a137a 100644 --- a/lib/mcp/tool/schema.rb +++ b/lib/mcp/tool/schema.rb @@ -5,6 +5,12 @@ require "json-schema" module MCP class Tool class Schema + # JSON Schema 2020-12 is the default dialect for MCP schema definitions + # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation + # is still performed against the JSON Schema draft-04 metaschema because + # the `json-schema` gem does not yet support 2020-12. + JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema" + attr_reader :schema def initialize(schema = {}) @@ -18,17 +24,18 @@ module MCP end def to_h - @schema + return @schema if @schema.key?(:"$schema") + + { "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema) end private def fully_validate(data) - JSON::Validator.fully_validate(to_h, data) + JSON::Validator.fully_validate(schema_for_validation, data) end def validate_schema! - schema = to_h gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path) schema_reader = JSON::Schema::Reader.new( accept_uri: false, @@ -38,11 +45,22 @@ module MCP # Converts metaschema to a file URI for cross-platform compatibility metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/")) metaschema = metaschema_uri.to_s - errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) + errors = JSON::Validator.fully_validate(metaschema, schema_for_validation, schema_reader: schema_reader) if errors.any? raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" end end + + # The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema` + # dialect URIs. Strip the top-level `$schema` before validation so a dialect URI + # (whether SDK-injected by `to_h` or user-supplied) does not break the validator. + def schema_for_validation + return @schema unless @schema.key?(:"$schema") + + copy = @schema.dup + copy.delete(:"$schema") + copy + end end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index a8f3ea0..1386ae2 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -263,7 +263,9 @@ module MCP assert_equal "test_tool", result[:tools][0][:name] assert_equal "Test tool", result[:tools][0][:title] assert_equal "A test tool", result[:tools][0][:description] - assert_equal({ type: "object" }, result[:tools][0][:inputSchema]) + assert_equal( + { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, result[:tools][0][:inputSchema] + ) assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) assert_instrumentation_data({ method: "tools/list" }) end @@ -284,6 +286,24 @@ module MCP assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) end + test "#handle tools/list emits 2020-12 $schema on inputSchema and outputSchema" do + tool_with_output = Tool.define( + name: "tool_with_output", + description: "tool with output schema", + input_schema: { properties: { msg: { type: "string" } } }, + output_schema: { properties: { result: { type: "string" } } }, + ) do + Tool::Response.new([{ type: "text", content: "OK" }]) + end + server = Server.new(name: "test_server", tools: [tool_with_output]) + + response = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) + tool = response[:result][:tools][0] + + assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:inputSchema][:"$schema"] + assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:outputSchema][:"$schema"] + end + test "#handle tools/call executes tool and returns result" do tool_name = "test_tool" tool_args = { arg: "value" } diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 03bce28..80a5782 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -13,7 +13,12 @@ module MCP test "to_h returns a hash representation of the input schema" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) assert_equal( - { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, input_schema.to_h, ) end @@ -21,11 +26,52 @@ module MCP test "to_h returns a hash representation of the input schema with additionalProperties set to false" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false) assert_equal( - { type: "object", properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + additionalProperties: false, + }, input_schema.to_h, ) end + test "to_h preserves user-supplied $schema dialect" do + input_schema = InputSchema.new( + "$schema": "https://json-schema.org/draft/2019-09/schema", + properties: { message: { type: "string" } }, + ) + assert_equal "https://json-schema.org/draft/2019-09/schema", input_schema.to_h[:"$schema"] + end + + test "validate_arguments works when user supplies a 2020-12 $schema" do + input_schema = InputSchema.new( + "$schema": "https://json-schema.org/draft/2020-12/schema", + properties: { foo: { type: "string" } }, + required: ["foo"], + ) + assert_nil(input_schema.validate_arguments(foo: "bar")) + assert_raises(InputSchema::ValidationError) do + input_schema.validate_arguments({ foo: 123 }) + end + end + + test "to_h preserves user-supplied $schema given via string key" do + # The initializer normalizes input through `JSON.parse(..., + # symbolize_names: true)`, so a string-keyed `"$schema"` should + # arrive at `schema_for_validation` the same as a symbol-keyed one. + input_schema = InputSchema.new( + { + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "properties" => { "foo" => { "type" => "string" } }, + "required" => ["foo"], + }, + ) + assert_equal "https://json-schema.org/draft/2020-12/schema", input_schema.to_h[:"$schema"] + assert_nil(input_schema.validate_arguments(foo: "bar")) + end + test "missing_required_arguments returns an array of missing required arguments" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) assert_equal ["message"], input_schema.missing_required_arguments({}) @@ -38,7 +84,15 @@ module MCP test "valid schema initialization" do schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }, + schema.to_h, + ) end test "invalid schema raises argument error" do diff --git a/test/mcp/tool/output_schema_test.rb b/test/mcp/tool/output_schema_test.rb index 5445462..073de2c 100644 --- a/test/mcp/tool/output_schema_test.rb +++ b/test/mcp/tool/output_schema_test.rb @@ -8,11 +8,38 @@ module MCP test "to_h returns a hash representation of the output schema" do output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_equal( - { type: "object", properties: { result: { type: "string" } }, required: ["result"] }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, output_schema.to_h, ) end + test "to_h preserves user-supplied $schema dialect" do + output_schema = OutputSchema.new( + "$schema": "https://json-schema.org/draft/2019-09/schema", + properties: { result: { type: "string" } }, + ) + assert_equal "https://json-schema.org/draft/2019-09/schema", output_schema.to_h[:"$schema"] + end + + test "validate_result works when user supplies a 2020-12 $schema" do + output_schema = OutputSchema.new( + "$schema": "https://json-schema.org/draft/2020-12/schema", + properties: { result: { type: "string" } }, + required: ["result"], + ) + assert_nothing_raised do + output_schema.validate_result({ result: "success" }) + end + assert_raises(OutputSchema::ValidationError) do + output_schema.validate_result({ result: 123 }) + end + end + test "validate_result validates result against the schema" do output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_nothing_raised do @@ -57,7 +84,15 @@ module MCP test "valid schema initialization" do schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }, + schema.to_h, + ) end test "invalid schema raises argument error" do @@ -136,7 +171,10 @@ module MCP test "empty schema is valid" do schema = OutputSchema.new - assert_equal({ type: "object" }, schema.to_h) + assert_equal( + { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, + schema.to_h, + ) end test "validates complex nested schemas" do @@ -187,6 +225,7 @@ module MCP }) assert_equal( { + "$schema": "https://json-schema.org/draft/2020-12/schema", type: "array", items: { properties: { foo: { type: "string" } }, diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index a0175ad..e32df0a 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -33,7 +33,7 @@ module MCP title: "Mock Tool", description: "a mock tool for testing", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], - inputSchema: { type: "object" }, + inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, } tool = Tool.define( name: "mock_tool", @@ -109,7 +109,15 @@ module MCP tool = MockTool assert_equal "my_mock_tool", tool.name_value assert_equal "a mock tool for testing", tool.description - assert_equal({ type: "object", properties: { message: { type: "string" } }, required: ["message"] }, tool.input_schema.to_h) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + tool.input_schema.to_h, + ) end test "defaults to class name as tool name" do @@ -126,7 +134,7 @@ module MCP tool = NoInputSchemaTool - expected = { type: "object" } + expected = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" } assert_equal expected, tool.input_schema.to_h end @@ -137,7 +145,12 @@ module MCP tool = InputSchemaTool - expected = { type: "object", properties: { message: { type: "string" } }, required: ["message"] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + } assert_equal expected, tool.input_schema.to_h end @@ -342,8 +355,13 @@ module MCP title: "Mock Tool", description: "a mock tool for testing", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], - inputSchema: { type: "object" }, - outputSchema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] }, + inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, + outputSchema: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, } tool = Tool.define( name: "mock_tool", @@ -376,7 +394,12 @@ module MCP end tool = HashOutputSchemaTool - expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected, tool.output_schema.to_h end @@ -386,7 +409,12 @@ module MCP end tool = OutputSchemaObjectTool - expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected, tool.output_schema.to_h end @@ -436,7 +464,12 @@ module MCP assert_equal "mock_tool", tool.name_value assert_equal "a mock tool for testing", tool.description assert_instance_of Tool::OutputSchema, tool.output_schema - expected_output_schema = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected_output_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected_output_schema, tool.output_schema.to_h end @@ -458,10 +491,20 @@ module MCP assert_equal "test_tool_with_output", tool.name_value assert_equal "a test tool with output schema", tool.description - expected_input = { type: "object", properties: { message: { type: "string" } }, required: ["message"] } + expected_input = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + } assert_equal expected_input, tool.input_schema.to_h - expected_output = { type: "object", properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] } + expected_output = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" }, success: { type: "boolean" } }, + required: ["result", "success"], + } assert_equal expected_output, tool.output_schema.to_h end From 5f9407350384fd622609a08d11b8a85a3c4f2e49 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 11 May 2026 08:46:40 -0400 Subject: [PATCH 069/247] docs: add dark mode support --- docs/_config.yml | 4 - docs/_includes/head_custom.html | 117 ++++++++++++++++++++++ docs/_includes/header_custom.html | 8 ++ docs/_sass/custom/custom.scss | 67 +++++++++++++ docs/assets/css/just-the-docs-dark.scss | 3 + docs/assets/css/just-the-docs-light.scss | 3 + docs/assets/images/icons/github.svg | 4 + docs/assets/images/icons/theme-dark.svg | 3 + docs/assets/images/icons/theme-light.svg | 11 ++ docs/assets/images/icons/theme-system.svg | 5 + 10 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 docs/_includes/head_custom.html create mode 100644 docs/_includes/header_custom.html create mode 100644 docs/_sass/custom/custom.scss create mode 100644 docs/assets/css/just-the-docs-dark.scss create mode 100644 docs/assets/css/just-the-docs-light.scss create mode 100644 docs/assets/images/icons/github.svg create mode 100644 docs/assets/images/icons/theme-dark.svg create mode 100644 docs/assets/images/icons/theme-light.svg create mode 100644 docs/assets/images/icons/theme-system.svg diff --git a/docs/_config.yml b/docs/_config.yml index d488c9e..b589e4e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -7,10 +7,6 @@ remote_theme: just-the-docs/just-the-docs include: - "_*" -# Header links -aux_links: - GitHub: https://github.com/modelcontextprotocol/ruby-sdk - # Search search_enabled: true diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html new file mode 100644 index 0000000..701d0ae --- /dev/null +++ b/docs/_includes/head_custom.html @@ -0,0 +1,117 @@ + diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html new file mode 100644 index 0000000..8b2c640 --- /dev/null +++ b/docs/_includes/header_custom.html @@ -0,0 +1,8 @@ +
+ + + + +
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 0000000..5149a2a --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1,67 @@ +.docs-header-controls { + display: flex; + height: 3.75rem; + align-items: stretch; + justify-content: flex-end; + margin-left: auto; +} + +@media (min-width: 50rem) { + .docs-header-controls { + height: 100%; + padding-right: 1rem; + } +} + +.docs-icon-button.site-button { + width: 3.75rem; + justify-content: center; + padding: 0; + color: inherit; + background-color: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-decoration: none; +} + +.docs-icon { + display: block; + width: 1.25rem; + height: 1.25rem; + background-color: currentColor; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.docs-icon--github { + -webkit-mask-image: url("/assets/images/icons/github.svg"); + mask-image: url("/assets/images/icons/github.svg"); +} + +.docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-system.svg"); + mask-image: url("/assets/images/icons/theme-system.svg"); +} + +:root[data-theme-preference="light"] .docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-light.svg"); + mask-image: url("/assets/images/icons/theme-light.svg"); +} + +:root[data-theme-preference="dark"] .docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-dark.svg"); + mask-image: url("/assets/images/icons/theme-dark.svg"); +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +:root[data-theme="light"] { + color-scheme: light; +} diff --git a/docs/assets/css/just-the-docs-dark.scss b/docs/assets/css/just-the-docs-dark.scss new file mode 100644 index 0000000..ac92fb1 --- /dev/null +++ b/docs/assets/css/just-the-docs-dark.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="dark" %} diff --git a/docs/assets/css/just-the-docs-light.scss b/docs/assets/css/just-the-docs-light.scss new file mode 100644 index 0000000..ac69688 --- /dev/null +++ b/docs/assets/css/just-the-docs-light.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="light" %} diff --git a/docs/assets/images/icons/github.svg b/docs/assets/images/icons/github.svg new file mode 100644 index 0000000..75db234 --- /dev/null +++ b/docs/assets/images/icons/github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/docs/assets/images/icons/theme-dark.svg b/docs/assets/images/icons/theme-dark.svg new file mode 100644 index 0000000..0571e5c --- /dev/null +++ b/docs/assets/images/icons/theme-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/images/icons/theme-light.svg b/docs/assets/images/icons/theme-light.svg new file mode 100644 index 0000000..e31928f --- /dev/null +++ b/docs/assets/images/icons/theme-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/assets/images/icons/theme-system.svg b/docs/assets/images/icons/theme-system.svg new file mode 100644 index 0000000..77dd0d4 --- /dev/null +++ b/docs/assets/images/icons/theme-system.svg @@ -0,0 +1,5 @@ + + + + + From f67cc745c3b9f8ad97b16b38d5cdf9d584be4ad7 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 11 May 2026 21:44:26 -0400 Subject: [PATCH 070/247] fix: preserve client tool output schemas --- lib/mcp/client.rb | 1 + test/mcp/client_test.rb | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index b651671..64d1f8d 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -123,6 +123,7 @@ module MCP name: tool["name"], description: tool["description"], input_schema: tool["inputSchema"], + output_schema: tool["outputSchema"], ) end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index e28aedc..d5cae9a 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -81,7 +81,12 @@ module MCP mock_response = { "result" => { "tools" => [ - { "name" => "tool1", "description" => "tool1", "inputSchema" => {} }, + { + "name" => "tool1", + "description" => "tool1", + "inputSchema" => {}, + "outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } }, + }, { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, ], }, @@ -97,7 +102,9 @@ module MCP assert_equal(2, tools.size) assert_equal("tool1", tools.first.name) + assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema) assert_equal("tool2", tools.last.name) + assert_nil(tools.last.output_schema) end def test_tools_returns_empty_array_when_no_tools From 407b064140636ff9e151fc60d5a234886d0f907f Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 11 May 2026 22:04:15 -0400 Subject: [PATCH 071/247] feat: add opt-in tool output validation --- README.md | 13 +++ lib/mcp/configuration.rb | 23 ++++- lib/mcp/server.rb | 12 ++- test/mcp/configuration_test.rb | 49 +++++++++++ test/mcp/server_test.rb | 150 +++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 71fb5ac..8bd4222 100644 --- a/README.md +++ b/README.md @@ -645,6 +645,19 @@ MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/l The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. +By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. To validate successful tool responses, enable `validate_tool_call_results`: + +```ruby +configuration = MCP::Configuration.new(validate_tool_call_results: true) +server = MCP::Server.new( + name: "example_server", + tools: [WeatherTool], + configuration: configuration +) +``` + +When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. Error responses are not validated against the output schema. + ### Tool Responses with Structured Content Tools can return structured data alongside text content using the `structured_content` parameter. diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 34fcdec..e24dc73 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -16,7 +16,7 @@ module MCP attr_writer :instrumentation_callback def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, - validate_tool_call_arguments: true) + validate_tool_call_arguments: true, validate_tool_call_results: false) @exception_reporter = exception_reporter @around_request = around_request @instrumentation_callback = instrumentation_callback @@ -25,8 +25,10 @@ module MCP validate_protocol_version!(protocol_version) end validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) + validate_value_of_validate_tool_call_results!(validate_tool_call_results) @validate_tool_call_arguments = validate_tool_call_arguments + @validate_tool_call_results = validate_tool_call_results end def protocol_version=(protocol_version) @@ -41,6 +43,12 @@ module MCP @validate_tool_call_arguments = validate_tool_call_arguments end + def validate_tool_call_results=(validate_tool_call_results) + validate_value_of_validate_tool_call_results!(validate_tool_call_results) + + @validate_tool_call_results = validate_tool_call_results + end + def protocol_version @protocol_version || LATEST_STABLE_PROTOCOL_VERSION end @@ -80,11 +88,16 @@ module MCP end attr_reader :validate_tool_call_arguments + attr_reader :validate_tool_call_results def validate_tool_call_arguments? !!@validate_tool_call_arguments end + def validate_tool_call_results? + !!@validate_tool_call_results + end + def merge(other) return self if other.nil? @@ -113,6 +126,7 @@ module MCP end validate_tool_call_arguments = other.validate_tool_call_arguments + validate_tool_call_results = other.validate_tool_call_results Configuration.new( exception_reporter: exception_reporter, @@ -120,6 +134,7 @@ module MCP instrumentation_callback: instrumentation_callback, protocol_version: protocol_version, validate_tool_call_arguments: validate_tool_call_arguments, + validate_tool_call_results: validate_tool_call_results, ) end @@ -138,6 +153,12 @@ module MCP end end + def validate_value_of_validate_tool_call_results!(validate_tool_call_results) + unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass) + raise ArgumentError, "validate_tool_call_results must be a boolean" + end + end + def default_exception_reporter @default_exception_reporter ||= ->(exception, server_context) {} end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c3cdf23..de2b4aa 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -586,9 +586,11 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - call_tool_with_args( + result = call_tool_with_args( tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation ) + validate_tool_call_result!(tool, result) + result rescue RequestHandlerError, CancelledError # CancelledError is intentionally not wrapped so `handle_request` can turn it into # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. @@ -745,6 +747,14 @@ module MCP ).to_h end + def validate_tool_call_result!(tool, result) + return unless configuration.validate_tool_call_results + return unless tool.output_schema + return if result[:isError] + + tool.output_schema.validate_result(result[:structuredContent]) + end + # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`. diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 8c395f1..8270b10 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -74,6 +74,14 @@ module MCP assert_equal("validate_tool_call_arguments must be a boolean", exception.message) end + test "raises ArgumentError when validate_tool_call_results is not a boolean value" do + config = Configuration.new + exception = assert_raises(ArgumentError) do + config.validate_tool_call_results = "true" + end + assert_equal("validate_tool_call_results must be a boolean", exception.message) + end + test "merges protocol version from other configuration" do config1 = Configuration.new(protocol_version: "2025-03-26") config2 = Configuration.new(protocol_version: "2025-06-18") @@ -123,6 +131,40 @@ module MCP refute merged.validate_tool_call_arguments end + test "defaults validate_tool_call_results to false" do + config = Configuration.new + refute config.validate_tool_call_results + end + + test "can set validate_tool_call_results to true" do + config = Configuration.new(validate_tool_call_results: true) + assert config.validate_tool_call_results + end + + test "validate_tool_call_results? returns false when not set" do + config = Configuration.new + refute config.validate_tool_call_results? + end + + test "validate_tool_call_results? returns true when set" do + config = Configuration.new(validate_tool_call_results: true) + assert config.validate_tool_call_results? + end + + test "merge preserves validate_tool_call_results from other config" do + config1 = Configuration.new(validate_tool_call_results: true) + config2 = Configuration.new + merged = config1.merge(config2) + refute merged.validate_tool_call_results? + end + + test "merge preserves validate_tool_call_results from self when other set" do + config1 = Configuration.new(validate_tool_call_results: true) + config2 = Configuration.new + merged = config2.merge(config1) + assert merged.validate_tool_call_results + end + test "initializes with a default pass-through around_request" do config = Configuration.new called = false @@ -183,5 +225,12 @@ module MCP end assert_equal("validate_tool_call_arguments must be a boolean", exception.message) end + + test "raises ArgumentError when validate_tool_call_results is not a boolean" do + exception = assert_raises(ArgumentError) do + Configuration.new(validate_tool_call_results: "true") + end + assert_equal("validate_tool_call_results must be a boolean", exception.message) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index a8f3ea0..27913d5 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1767,6 +1767,156 @@ module MCP assert_includes response[:result][:content][0][:text], "Invalid arguments" end + test "tools/call skips output schema validation by default" do + tool = Tool.define( + name: "invalid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: 123 }, + ) + end + server = Server.new(tools: [tool]) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "invalid_structured_content_tool" }, + }) + + assert_nil response[:error] + assert_equal({ result: 123 }, response[:result][:structuredContent]) + end + + test "tools/call validates structuredContent against output schema when enabled" do + tool = Tool.define( + name: "valid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: "success" }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "valid_structured_content_tool" }, + }) + + assert_nil response[:error] + assert_equal({ result: "success" }, response[:result][:structuredContent]) + end + + test "tools/call returns JSON-RPC error for invalid structuredContent when output schema validation is enabled" do + tool = Tool.define( + name: "invalid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: 123 }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "invalid_structured_content_tool" }, + }) + + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, response[:error][:data]) + end + + test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do + tool = Tool.define( + name: "missing_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "missing_structured_content_tool" }, + }) + + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, response[:error][:data]) + end + + test "tools/call skips output schema validation for error responses" do + tool = Tool.define( + name: "error_response_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "failed" }], + error: true, + structured_content: { result: 123 }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "error_response_tool" }, + }) + + assert_nil response[:error] + assert response[:result][:isError] + assert_equal({ result: 123 }, response[:result][:structuredContent]) + end + test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do server = Server.new( tools: [TestTool], From 9c2e438d4aec6816748ec542d9903b24caf0a139 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 00:37:53 +0900 Subject: [PATCH 072/247] Validate `MCP-Protocol-Version` header in StreamableHTTPTransport ## Motivation and Context MCP 2025-11-25 (basic/transports#protocol-version-header) requires servers to respond with `400 Bad Request` when receiving a request with an invalid or unsupported `MCP-Protocol-Version` header: > If the server receives a request with an invalid or unsupported > `MCP-Protocol-Version`, it MUST respond with `400 Bad Request`. The Ruby SDK was not reading the header at all, accepting any value (including malformed strings like `not-a-version` and unsupported versions like `1900-01-01`) and dispatching requests normally with HTTP 200. ## Behavior `StreamableHTTPTransport` now validates the `MCP-Protocol-Version` header on POST (non-initialize), GET, and DELETE requests. Missing headers are accepted as before; the spec SHOULD requirement to default to `2025-03-26` is intentionally left for a follow-up. The `initialize` POST is exempt because the client does not know the negotiated version until the response arrives, matching the Python (`src/mcp/server/streamable_http.py`) and TypeScript (`packages/server/src/server/streamableHttp.ts`) SDKs. The error response uses a JSON-RPC envelope with `code: -32600` (`INVALID_REQUEST`) to match the Python SDK shape: ```json { "jsonrpc": "2.0", "id": null, "error": { "code": -32600, "message": "Bad Request: Unsupported protocol version: . Supported versions: ..." } } ``` Validation order is session then protocol version, matching the Python and TypeScript SDKs. `handle_post` was reorganized so that non-Hash request bodies (e.g., JSON arrays, which MCP 2025-11-25 no longer supports as batches) also pass through the header check. The pre-existing broken response for Array bodies sent with a valid header is unchanged and is tracked as a separate follow-up. ## How Has This Been Tested? Added regression tests covering: - POST `initialize` ignores the header (bypass) - POST non-initialize with unsupported / malformed / valid / missing values - POST array body with unsupported value returns 400 - GET with unsupported / missing values - DELETE with unsupported value in both stateful and stateless modes - DELETE validates session before protocol version `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes Strictly additive for compliant clients. Third-party clients sending stale or malformed `MCP-Protocol-Version` values will now receive `400` instead of `200`, which is the intended spec behavior. Fixes #346. --- .../transports/streamable_http_transport.rb | 66 ++++- .../streamable_http_transport_test.rb | 252 ++++++++++++++++++ 2 files changed, 305 insertions(+), 13 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8dc8af0..9258ec1 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -340,23 +340,28 @@ module MCP session_id = extract_session_id(request) body = parse_request_body(body_string) - return body unless body.is_a?(Hash) # Error response + return body if parse_error_tuple?(body) - if body[:method] == "initialize" - handle_initialization(body_string, body) - else + unless initialize_request?(body) return missing_session_id_response if !@stateless && !session_id - if notification?(body) - dispatch_notification(body_string, session_id) - handle_accepted - elsif response?(body) - return session_not_found_response if !@stateless && !session_exists?(session_id) + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + end - handle_response(body, session_id: session_id) - else - handle_regular_request(body_string, session_id, related_request_id: body[:id]) - end + return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25. + + if initialize_request?(body) + handle_initialization(body_string, body) + elsif notification?(body) + dispatch_notification(body_string, session_id) + handle_accepted + elsif response?(body) + return session_not_found_response if !@stateless && !session_exists?(session_id) + + handle_response(body, session_id: session_id) + else + handle_regular_request(body_string, session_id, related_request_id: body[:id]) end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) @@ -377,6 +382,10 @@ module MCP error_response = validate_and_touch_session(session_id) return error_response if error_response + + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + return session_already_connected_response if get_session_stream(session_id) setup_sse_stream(session_id) @@ -386,6 +395,9 @@ module MCP success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]] if @stateless + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + # Stateless mode doesn't support sessions, so we can just return a success response return success_response end @@ -393,6 +405,9 @@ module MCP return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"]) return session_not_found_response unless session_exists?(session_id) + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + cleanup_session(session_id) success_response @@ -495,6 +510,31 @@ module MCP [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end + def parse_error_tuple?(body) + body.is_a?(Array) && body.size == 3 && body[0] == 400 + end + + def initialize_request?(body) + body.is_a?(Hash) && body[:method] == Methods::INITIALIZE + end + + def validate_protocol_version_header(request) + header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] + return if header_value.nil? + return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) + + supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ") + body = { + jsonrpc: "2.0", + id: nil, + error: { + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}", + }, + } + [400, { "Content-Type" => "application/json" }, [body.to_json]] + end + def notification?(body) !body[:id] && !!body[:method] end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index b2de1f4..9d74398 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1385,6 +1385,258 @@ module MCP assert_equal "text/event-stream", response[1]["Content-Type"] end + test "POST initialize request ignores MCP-Protocol-Version header" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => "1900-01-01", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with unsupported MCP-Protocol-Version returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_includes body["error"]["message"], "1999-01-01" + assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION + end + + test "POST request with malformed MCP-Protocol-Version returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "not-a-version", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"]["message"], "not-a-version" + end + + test "POST request with supported MCP-Protocol-Version succeeds" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION, + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request without MCP-Protocol-Version header succeeds" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with array body and unsupported MCP-Protocol-Version returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + [{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + + test "GET request with unsupported MCP-Protocol-Version returns 400" do + 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"] + + request = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + + test "GET request without MCP-Protocol-Version header succeeds" do + 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"] + + request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "DELETE request with unsupported MCP-Protocol-Version returns 400" do + 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"] + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + + test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01" }, + ) + + response = stateless_transport.handle_request(request) + assert_equal 400, response[0] + end + + test "DELETE request validates session before MCP-Protocol-Version" do + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => "unknown-session-id", + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + end + test "stateless mode allows requests without session IDs, responding with no session ID" do stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) From c7b059cb9168efc0deb24ce6396c5ffc78d1ecc7 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 12:00:39 +0900 Subject: [PATCH 073/247] Release 0.16.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 15 +++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e844c79..5f2556e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] - 2026-05-14 + +### Added + +- Add opt-in tool output validation (#344) + +### Changed + +- Advertise JSON Schema 2020-12 dialect on emitted tool schemas (#342) + +### Fixed + +- Preserve client tool output schemas (#343) +- Fix missing `output_schema` argument in `define_tool` API (#301) + ## [0.15.0] - 2026-05-05 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 696262e..4410816 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.15.0" + VERSION = "0.16.0" end From 711f0640882e846e8a7b111899a47bab13df812e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 16 May 2026 21:37:34 +0900 Subject: [PATCH 074/247] Document `initialize` body `protocolVersion` is authoritative on Streamable HTTP ## Motivation and Context When an `initialize` POST carries an `MCP-Protocol-Version` HTTP header that disagrees with `params.protocolVersion` in the JSON-RPC body, the Streamable HTTP server accepts the request and negotiates using the body value. The behavior was already encoded in `handle_post` (the header check is skipped on `initialize`) but it was not stated in the code and there was no regression test for the specific mismatch case. MCP 2025-06-18 / 2025-11-25 only requires `MCP-Protocol-Version` on requests subsequent to `initialize`, and the spec does not require the header and body to agree on the initial `initialize`. All official MCP SDKs (TypeScript, Python, Rust, Java, Go, PHP) accept the mismatch and treat the JSON-RPC body as authoritative. ## How Has This Been Tested? Streamable HTTP tests: two new regression tests confirm that on an `initialize` POST with a supported but mismatched `MCP-Protocol-Version` header, the server returns HTTP 200 and the response's `result.protocolVersion` matches the JSON-RPC body value, in both mismatch directions (header older / body newer, and header newer / body older). ## Breaking Changes None. The behavior is unchanged: `initialize` requests with a mismatched `MCP-Protocol-Version` header are still accepted, and the JSON-RPC body `params.protocolVersion` continues to drive negotiation. This commit only adds an explanatory comment and regression tests. Closes #351. --- .../transports/streamable_http_transport.rb | 3 ++ .../streamable_http_transport_test.rb | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 9258ec1..77d97cc 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -342,6 +342,9 @@ module MCP body = parse_request_body(body_string) return body if parse_error_tuple?(body) + # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` + # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. + # This matches the TypeScript and Python SDKs. unless initialize_request?(body) return missing_session_id_response if !@stateless && !session_id diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9d74398..a753789 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1400,6 +1400,58 @@ module MCP assert_equal 200, response[0] end + test "POST initialize request negotiates body protocolVersion when header is an older supported version" do + older_version = "2025-06-18" + assert_includes Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, older_version + refute_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, older_version + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => older_version, + }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION }, + }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + end + + test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do + older_version = "2025-06-18" + assert_includes Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, older_version + refute_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, older_version + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION, + }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: { protocolVersion: older_version }, + }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal older_version, body["result"]["protocolVersion"] + end + test "POST request with unsupported MCP-Protocol-Version returns 400" do init_request = create_rack_request( "POST", From 576606992316b3957ffd2057c2ce18bea8092688 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 10 May 2026 23:47:22 +0900 Subject: [PATCH 075/247] Add OAuth 2.1 client support for MCP authorization flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation and Context The MCP Authorization spec (2025-11-25) requires clients to support Protected Resource Metadata discovery (RFC 9728), Authorization Server Metadata discovery (RFC 8414), Dynamic Client Registration, and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK previously had no client-side OAuth support: the HTTP transport raised `RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios were listed as known failures in `conformance/expected_failures.yml`. This aligns the Ruby SDK with the Python and TypeScript SDKs, both of which expose a pluggable OAuth client provider that drives the full discovery + DCR + authorization + token exchange flow. `MCP::Client::OAuth::Provider` mirrors that surface: callers supply `client_metadata`, a `redirect_uri`, and pluggable `redirect_handler` / `callback_handler` hooks plus a token/client information storage. `MCP::Client::OAuth::InMemoryStorage` is provided as a default. `MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present, the transport attaches `Authorization: Bearer` to every request, and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL and `scope` challenge, drives the discovery + DCR + authorization code flow through the provider, and retries the failed request once with the acquired token. Spec compliance details: - The `WWW-Authenticate` parser handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only the parameters that belong to the Bearer challenge. - The PRM `resource` value is required to cover the MCP server URL (same scheme/host/port, with the PRM path as a prefix of the server path) before the flow continues, preventing redirection of issued tokens to a different audience. - Both authorization and token requests always include a `resource` parameter (RFC 8707), defaulting to a canonicalized form of the MCP server URL when PRM does not advertise one. - `client_secret_basic`, `client_secret_post`, and `none` token endpoint authentication methods are supported. When pre-registered client information specifies no `token_endpoint_auth_method`, the transport defaults to `client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention. Pre-registered client credentials supplied via `Storage#save_client_information` with either string or symbol keys are honored, skipping DCR. - Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first, then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope). - Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with `error: "invalid_grant"` raises `Flow::InvalidGrantError`, so `MCP::Client::HTTP` can discard the stored refresh token only when the AS truly says it is dead. Generic `AuthorizationError` (5xx, network failures, other error codes) leaves the refresh token intact, so a transient AS outage does not force interactive reauth on the next attempt. - The OAuth `state` parameter is compared via `OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to prevent timing-based discovery of the expected value. - The 401 retry guard is a request-scoped local variable, so an MCP server that keeps returning 401 is surfaced as an error after one retry rather than triggering an infinite re-authorization loop. ## How Has This Been Tested? Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing, PRM and Authorization Server metadata URL builders, the full Flow orchestrator (success path, state mismatch, missing PKCE support, skipping DCR for both string and symbol-keyed preregistered client information, `client_secret_basic` token endpoint authentication, PRM `resource` mismatch rejection, fallback to the server URL when PRM omits `resource`, scope resolution preferring PRM `scopes_supported` over `Provider#scope`, default `client_secret_basic` for confidential clients without an explicit `token_endpoint_auth_method`, and the new `InvalidGrantError` vs generic `AuthorizationError` split for token endpoint rejections), and the HTTP transport behavior (401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401, no infinite loop on flow failure, refresh-token retention on transient 5xx from the token endpoint). Conformance: `bundle exec rake conformance` newly passes 16 `auth/*` scenarios beyond the existing baseline, including `auth/metadata-default`, `metadata-var{1,2,3}`, `scope-from-www-authenticate`, `scope-from-scopes-supported`, `scope-omitted-when-undefined`, `scope-retry-limit`, `token-endpoint-auth-{basic,post,none}`, `pre-registration`, `resource-mismatch`, and `offline-access-not-supported`. Remaining `auth/*` scenarios stay in `expected_failures.yml`. `bundle exec rake test rubocop` is clean. ## Breaking Changes None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:` keyword that defaults to `nil`, preserving existing behavior. When unset, the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before. --- README.md | 107 ++ conformance/client.rb | 84 +- conformance/expected_failures.yml | 15 +- lib/mcp/client.rb | 1 + lib/mcp/client/http.rb | 257 +++- lib/mcp/client/oauth.rb | 18 + lib/mcp/client/oauth/discovery.rb | 423 +++++++ lib/mcp/client/oauth/flow.rb | 587 +++++++++ lib/mcp/client/oauth/in_memory_storage.rb | 43 + lib/mcp/client/oauth/pkce.rb | 38 + lib/mcp/client/oauth/provider.rb | 103 ++ test/mcp/client/oauth/discovery_test.rb | 467 ++++++++ test/mcp/client/oauth/flow_test.rb | 1318 +++++++++++++++++++++ test/mcp/client/oauth/http_oauth_test.rb | 1086 +++++++++++++++++ test/mcp/client/oauth/pkce_test.rb | 34 + test/mcp/client/oauth/provider_test.rb | 89 ++ 16 files changed, 4600 insertions(+), 70 deletions(-) create mode 100644 lib/mcp/client/oauth.rb create mode 100644 lib/mcp/client/oauth/discovery.rb create mode 100644 lib/mcp/client/oauth/flow.rb create mode 100644 lib/mcp/client/oauth/in_memory_storage.rb create mode 100644 lib/mcp/client/oauth/pkce.rb create mode 100644 lib/mcp/client/oauth/provider.rb create mode 100644 test/mcp/client/oauth/discovery_test.rb create mode 100644 test/mcp/client/oauth/flow_test.rb create mode 100644 test/mcp/client/oauth/http_oauth_test.rb create mode 100644 test/mcp/client/oauth/pkce_test.rb create mode 100644 test/mcp/client/oauth/provider_test.rb diff --git a/README.md b/README.md index 8bd4222..3f54b31 100644 --- a/README.md +++ b/README.md @@ -1885,6 +1885,113 @@ client.tools # will make the call using Bearer auth You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. +#### OAuth 2.1 Authorization + +When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), +pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: + +- Send `Authorization: Bearer ` on every request when a token is available. +- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), + perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. +- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). + +```ruby +require "mcp" + +provider = MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "My MCP App", + redirect_uris: ["http://localhost:3030/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:3030/callback", + redirect_handler: ->(authorization_url) { + # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` + # or a manual `puts authorization_url` in CLI tools. + }, + callback_handler: -> { + # Capture the redirect (for example, by running a small HTTP listener on + # `redirect_uri`) and return [code, state] from the query string. + }, +) + +transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + oauth: provider, +) +client = MCP::Client.new(transport: transport) +client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token +client.tools +``` + +Required keyword arguments to `Provider.new`: + +- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, + `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. +- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. +- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. +- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`. + +Optional keyword arguments: + +- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, + which keeps credentials in process memory only. + +To persist credentials across restarts, supply your own storage: + +```ruby +class FileTokenStorage + def initialize(path) + @path = path + end + + def tokens + read["tokens"] + end + + def save_tokens(value) + write("tokens" => value) + end + + def client_information + read["client"] + end + + def save_client_information(value) + write("client" => value) + end + + private + + def read + File.exist?(@path) ? JSON.parse(File.read(@path)) : {} + end + + def write(updates) + File.write(@path, JSON.dump(read.merge(updates))) + end +end + +provider = MCP::Client::OAuth::Provider.new( + # ... required keywords ... + storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), +) +``` + +##### Communication Security + +When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, +`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. + +The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through +a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, +whether the swap was triggered by +`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, +and whether the new URL is `http://` *or* `https://` to a different host or tenant. + #### Customizing the Faraday Connection You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. diff --git a/conformance/client.rb b/conformance/client.rb index 5d8ba11..f1efe61 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -8,6 +8,8 @@ # The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, # which is set automatically by the conformance test runner. +require "faraday" +require "json" require_relative "../lib/mcp" scenario = ENV["MCP_CONFORMANCE_SCENARIO"] @@ -17,7 +19,82 @@ unless scenario && server_url abort("Usage: MCP_CONFORMANCE_SCENARIO= ruby conformance/client.rb ") end -transport = MCP::Client::HTTP.new(url: server_url) +# The conformance harness optionally injects scenario-specific data via +# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is +# defined by the harness, not the MCP spec, and has varied between versions: +# +# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are +# spread at the top level alongside `name`, e.g. +# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`. +# - Older: a nested `context` object: `{"name":"...","context":{...}}`. +# +# Both shapes are accepted so the client conforms to whichever harness version +# the developer has on hand. +def conformance_context + raw = ENV["MCP_CONFORMANCE_CONTEXT"] + return {} if raw.nil? || raw.empty? + + parsed = JSON.parse(raw) + return {} unless parsed.is_a?(Hash) + + if parsed["context"].is_a?(Hash) + parsed["context"] + else + parsed.reject { |key, _| key == "name" } + end +rescue JSON::ParserError + {} +end + +# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow +# non-interactively against the conformance test's auth server. The conformance +# `/authorize` endpoint redirects synchronously to `redirect_uri` with +# `code=test-auth-code`, so we follow it manually instead of opening a browser. +def build_oauth_provider(context) + callback_holder = {} + redirect_uri = "http://localhost:0/callback" + + redirect_handler = ->(authorization_url) do + response = Faraday.new.get(authorization_url) do |req| + req.options.params_encoder = nil + end + location = response.headers["location"] || response.headers["Location"] + abort("Authorization request did not redirect: #{response.status}.") unless location + + callback_holder[:url] = URI.parse(location) + end + + callback_handler = -> do + query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h + [query["code"], query["state"]] + end + + storage = MCP::Client::OAuth::InMemoryStorage.new + if context["client_id"] + storage.save_client_information( + "client_id" => context["client_id"], + "client_secret" => context["client_secret"], + "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end + + MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "ruby-sdk-conformance-client", + redirect_uris: [redirect_uri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: redirect_uri, + redirect_handler: redirect_handler, + callback_handler: callback_handler, + storage: storage, + ) +end + +oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : nil +transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) @@ -29,6 +106,11 @@ when "tools_call" add_numbers = tools.find { |t| t.name == "add_numbers" } abort("Tool add_numbers not found") unless add_numbers client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) +when %r|\Aauth/| + # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) + # are observed by the conformance server during `connect` and the subsequent request below. + # Listing tools forces a second authenticated MCP request so the bearer token usage check fires. + client.tools else abort("Unknown or unsupported scenario: #{scenario}") end diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 080ef91..acac417 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -4,25 +4,12 @@ client: - sse-retry # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults - # TODO: OAuth/auth not implemented in Ruby client. - - auth/metadata-default - - auth/metadata-var1 - - auth/metadata-var2 - - auth/metadata-var3 + # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - auth/basic-cimd - - auth/scope-from-www-authenticate - - auth/scope-from-scopes-supported - - auth/scope-omitted-when-undefined - auth/scope-step-up - - auth/scope-retry-limit - - auth/token-endpoint-auth-basic - - auth/token-endpoint-auth-post - - auth/token-endpoint-auth-none - - auth/pre-registration - auth/2025-03-26-oauth-metadata-backcompat - auth/2025-03-26-oauth-endpoint-fallback - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow - auth/offline-access-scope - - auth/offline-access-not-supported diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 64d1f8d..9a22bd6 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "client/oauth" require_relative "client/stdio" require_relative "client/http" require_relative "client/paginated_result" diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 3eaabee..f5bf93d 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -17,12 +17,74 @@ module MCP SESSION_ID_HEADER = "Mcp-Session-Id" PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" - attr_reader :url, :session_id, :protocol_version, :server_info + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor + # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host + # is trivially observed and stolen. + class InsecureURLError < ArgumentError; end + + # Faraday request middleware that compares the outgoing request URL + # against the URL snapshotted at `MCP::Client::HTTP#initialize` time. + # Registered after the user's customizer so it sees `env.url` *after* + # any custom middleware has had a chance to rewrite it - closing + # the `Faraday env.url = URI("https://attacker...")` bypass that a plain + # `client.url_prefix` check would miss. The comparison includes + # the query string, so a middleware that rewrites `env.url.query` to + # a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise + # the audience-binding check on the OAuth side could be bypassed at + # the send step. + class OAuthURLGuard + def initialize(app, expected_url:) + @app = app + @expected_url = expected_url + end + + def call(env) + effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s) + unless effective == @expected_url + # Surface the *canonicalized* form (no userinfo, no fragment) so + # credentials like `user:pass@` cannot leak into logs, stack + # traces, or exception reporters. + raise InsecureURLError, + "Effective request URL #{effective.inspect} does not match the URL " \ + "validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token." + end + @app.call(env) + end + end + + attr_reader :url, :session_id, :protocol_version, :server_info, :oauth + + def initialize(url:, headers: {}, oauth: nil, &block) + if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) + # Mask credentials (userinfo) and query parameters before quoting the URL in the error message + # so they cannot leak into logs. + safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url) + raise InsecureURLError, + "MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \ + "sending bearer tokens over plain http to a remote host would leak them on the wire." + end - def initialize(url:, headers: {}, &block) @url = url @headers = headers @faraday_customizer = block + @oauth = oauth + # Snapshot the canonical URL at construction time. This single value + # serves two related roles, both of which need to see the query string: + # + # - As the RFC 8707 `resource` claim sent on the authorization and + # token requests (and as the base for PRM discovery URLs) - + # matching the TS / Python SDKs' `resourceUrlFromServerUrl` / + # `resource_url_from_server_url` so multi-tenant servers that scope + # by `?tenant=...` round-trip correctly. + # - As the comparison value for the URL guard middleware. Comparing + # query strings as well as origin + path is required so a Faraday + # middleware that rewrites `env.url.query` to a different tenant + # cannot send the bearer token to the wrong audience while + # the resource binding on the OAuth side stays correct. + # + # Saved only when `oauth:` is set so non-OAuth transports keep their + # existing behavior. + @oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil @session_id = nil @protocol_version = nil @server_info = nil @@ -30,8 +92,8 @@ module MCP end # Performs the MCP `initialize` handshake: sends an `initialize` request - # followed by the required `notifications/initialized` notification. The - # server's `InitializeResult` (protocol version, capabilities, server + # followed by the required `notifications/initialized` notification. + # The server's `InitializeResult` (protocol version, capabilities, server # info, instructions) is cached on the transport and returned. # # Idempotent: a second call returns the cached `InitializeResult` without @@ -122,68 +184,80 @@ module MCP def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] + oauth_retried = false - response = client.post("", request, session_headers) - body = parse_response_body(response, method, params) + begin + response = client.post("", request, session_headers) + body = parse_response_body(response, method, params) - capture_session_info(method, response, body) + capture_session_info(method, response, body) - body - rescue Faraday::BadRequestError => e - raise RequestHandlerError.new( - "The #{method} request is invalid", - { method: method, params: params }, - error_type: :bad_request, - original_error: e, - ) - rescue Faraday::UnauthorizedError => e - raise RequestHandlerError.new( - "You are unauthorized to make #{method} requests", - { method: method, params: params }, - error_type: :unauthorized, - original_error: e, - ) - rescue Faraday::ForbiddenError => e - raise RequestHandlerError.new( - "You are forbidden to make #{method} requests", - { method: method, params: params }, - error_type: :forbidden, - original_error: e, - ) - rescue Faraday::ResourceNotFound => e - # Per spec, 404 is the session-expired signal only when the request - # actually carried an `Mcp-Session-Id`. A 404 without a session attached - # (e.g. wrong URL or a stateless server) surfaces as a generic not-found. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management - if @session_id - clear_session - raise SessionExpiredError.new( - "The #{method} request is not found", + body + rescue Faraday::BadRequestError => e + raise RequestHandlerError.new( + "The #{method} request is invalid", { method: method, params: params }, + error_type: :bad_request, original_error: e, ) - else + rescue Faraday::UnauthorizedError => e + # Run the OAuth flow at most once per `send_request` invocation. + # The `oauth_retried` flag lives outside the `begin` so it survives `retry`, + # ensuring a server returning 401 indefinitely raises rather than loops. + if @oauth && !oauth_retried + oauth_retried = true + run_oauth_flow!(unauthorized_error: e) + retry + end + raise RequestHandlerError.new( - "The #{method} request is not found", + "You are unauthorized to make #{method} requests", { method: method, params: params }, - error_type: :not_found, + error_type: :unauthorized, + original_error: e, + ) + rescue Faraday::ForbiddenError => e + raise RequestHandlerError.new( + "You are forbidden to make #{method} requests", + { method: method, params: params }, + error_type: :forbidden, + original_error: e, + ) + rescue Faraday::ResourceNotFound => e + # Per spec, 404 is the session-expired signal only when the request + # actually carried an `Mcp-Session-Id`. A 404 without a session attached + # (e.g. wrong URL or a stateless server) surfaces as a generic not-found. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + if @session_id + clear_session + raise SessionExpiredError.new( + "The #{method} request is not found", + { method: method, params: params }, + original_error: e, + ) + else + raise RequestHandlerError.new( + "The #{method} request is not found", + { method: method, params: params }, + error_type: :not_found, + original_error: e, + ) + end + rescue Faraday::UnprocessableEntityError => e + raise RequestHandlerError.new( + "The #{method} request is unprocessable", + { method: method, params: params }, + error_type: :unprocessable_entity, + original_error: e, + ) + rescue Faraday::Error => e + raise RequestHandlerError.new( + "Internal error handling #{method} request", + { method: method, params: params }, + error_type: :internal_error, original_error: e, ) end - rescue Faraday::UnprocessableEntityError => e - raise RequestHandlerError.new( - "The #{method} request is unprocessable", - { method: method, params: params }, - error_type: :unprocessable_entity, - original_error: e, - ) - rescue Faraday::Error => e # Catch-all - raise RequestHandlerError.new( - "Internal error handling #{method} request", - { method: method, params: params }, - error_type: :internal_error, - original_error: e, - ) end # Terminates the session by sending an HTTP DELETE to the MCP endpoint @@ -228,20 +302,93 @@ module MCP end @faraday_customizer&.call(faraday) + + # Register the URL identity guard *after* the user's customizer + # so it sits closest to the adapter in the request stack. That way + # the guard sees `env.url` after any customizer-added middleware has had + # a chance to rewrite it, closing the `env.url = URI("https://...")` + # bypass that a `Faraday::Connection#url_prefix` check cannot detect. + faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url end end # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one) # and `MCP-Protocol-Version` on all requests after `initialize`. + # # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header def session_headers request_headers = {} request_headers[SESSION_ID_HEADER] = @session_id if @session_id request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version + if @oauth && (token = @oauth.access_token) + request_headers["Authorization"] = "Bearer #{token}" + end request_headers end + # Drives the OAuth orchestrator on a 401 from the MCP endpoint. + # The `WWW-Authenticate` header (when present) supplies the `resource_metadata` + # URL and an optional `scope` challenge per RFC 9728 Section 5.1. + # + # If the provider already holds a refresh token, we try to exchange it + # for a fresh access token first; only when that fails (e.g., the refresh token was revoked) + # do we fall back to the full Authorization Code + PKCE + DCR flow. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling + def run_oauth_flow!(unauthorized_error:) + response = unauthorized_error.response || {} + response_headers = response[:headers] || {} + www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"] + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate) + + flow = MCP::Client::OAuth::Flow.new(provider: @oauth) + if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"]) + return + end + + # Use the URL snapshotted at `initialize` time so a post-construction + # mutation of `@url` cannot redirect PRM/AS discovery and the authorize + # URL to an attacker-controlled host. + flow.run!( + server_url: @oauth_server_url, + resource_metadata_url: params["resource_metadata"], + scope: params["scope"], + ) + end + + # Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy + # on success and falsy on either "no refresh token available" or "refresh attempt failed" + # (in which case the caller should run the full interactive flow). + def attempt_refresh(flow:, resource_metadata_url:) + return false unless refresh_token_available? + + # Use the snapshotted URL for the same reason as `run_oauth_flow!` above: + # post-construction `@url` mutation must not redirect token-refresh + # discovery to an attacker-controlled host. Use the query-bearing form + # so the refresh request's RFC 8707 `resource` claim matches + # the original authorization request. + flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url) + true + rescue MCP::Client::OAuth::Flow::InvalidGrantError + # The refresh token has been revoked or expired by the AS. Wipe it so + # the full interactive flow runs fresh on the retry. + @oauth.clear_tokens! + false + rescue MCP::Client::OAuth::Flow::AuthorizationError + # Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh + # token in place; the next attempt may succeed and we avoid forcing + # the user through an interactive reauth for a recoverable error. + false + end + + def refresh_token_available? + tokens = @oauth.tokens + return false unless tokens.is_a?(Hash) + + !(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty? + end + def capture_session_info(method, response, body) return unless method.to_s == Methods::INITIALIZE diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb new file mode 100644 index 0000000..5115118 --- /dev/null +++ b/lib/mcp/client/oauth.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "oauth/discovery" +require_relative "oauth/flow" +require_relative "oauth/in_memory_storage" +require_relative "oauth/pkce" +require_relative "oauth/provider" + +module MCP + class Client + # OAuth client support for the MCP Authorization spec (PRM discovery, + # Authorization Server metadata discovery, Dynamic Client Registration, + # OAuth 2.1 Authorization Code + PKCE). + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + module OAuth + end + end +end diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb new file mode 100644 index 0000000..87f294d --- /dev/null +++ b/lib/mcp/client/oauth/discovery.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +require "ipaddr" +require "uri" + +module MCP + class Client + module OAuth + # Stateless helpers that map MCP-authorization spec URLs and headers into something + # the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on. + # The module bundles five concerns that share no state but are closely related to + # the spec's "Discovery" and "Communication Security" sections: + # + # - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls + # the Bearer challenge parameters (`resource_metadata`, `scope`, `error`, + # ...) out of a header that may carry multiple challenges per RFC 7235 + # and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6. + # - **Discovery URL builders** (`protected_resource_metadata_urls`, + # `authorization_server_metadata_urls`): list the candidate well-known + # URLs to probe when no explicit metadata URL is supplied, + # in the priority order required by RFC 9728 and RFC 8414. + # - **Communication Security check** (`secure_url?`): enforces "HTTPS only" + # for every OAuth-facing URL, with the loopback carve-out described in + # `secure_url?`'s comment. + # - **URL canonicalization** (`canonicalize_url`): normalizes scheme, + # host, port, path, percent-encoded dot segments, and fragments + # so two URLs that *refer to the same resource* compare as equal, + # and drops userinfo so credentials never reach the RFC 8707 `resource` claim + # or any error message. + # - **Resource coverage** (`resource_covers?`): decides whether a PRM `resource` URI + # is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits + # "under" the resource per RFC 8707 audience semantics. + # + # Every entry point is a class method so it can be called from initializers and + # from any thread without synchronization. + module Discovery + # Matches a single `key=value` pair inside an HTTP auth-scheme challenge. + # `value` is either a quoted string (which can contain commas and spaces) + # or a bare token, per RFC 7235. + WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/ + + class << self + # Parses a `WWW-Authenticate` header and returns the parameters of + # the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`, + # `scope`, `error`). Returns `{}` when no Bearer challenge is present. + # Handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`) + # by extracting only the Bearer parameters. + # + # - https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1 + # - https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1 + def parse_www_authenticate(header) + return {} unless header + + # Locate the Bearer challenge: at the start of the header or after a comma. + bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i) + return {} unless bearer + + # Walk key=value pairs starting where Bearer's parameters begin. + # The loop stops at the first token that is not a key=value pair, + # which marks the next challenge (e.g. `, DPoP algs="..."`). + cursor = bearer.end(0) + params = {} + while cursor < header.length + prefix = header[cursor..] + prefix = prefix.sub(/\A\s*,?\s*/, "") + break if prefix.empty? + + match = prefix.match(WWW_AUTH_PARAM_PATTERN) + break unless match + + params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3] + cursor = header.length - prefix.length + match.end(0) + end + params + end + + # Returns the candidate Protected Resource Metadata URLs to probe, in priority order. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements + def protected_resource_metadata_urls(server_url:, resource_metadata_url: nil) + urls = [] + urls << resource_metadata_url if resource_metadata_url + + uri = URI.parse(server_url) + path = uri.path == "/" ? "" : uri.path.to_s + base = base_url(uri) + + urls << "#{base}/.well-known/oauth-protected-resource#{path}" + urls << "#{base}/.well-known/oauth-protected-resource" + urls.uniq + end + + # Returns the candidate Authorization Server metadata URLs to probe, in priority order. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery + def authorization_server_metadata_urls(issuer_url) + uri = URI.parse(issuer_url) + path = uri.path == "/" ? "" : uri.path.to_s + base = base_url(uri) + + if path.empty? + ["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"] + else + [ + "#{base}/.well-known/oauth-authorization-server#{path}", + "#{base}/.well-known/openid-configuration#{path}", + "#{base}#{path}/.well-known/openid-configuration", + ] + end + end + + # Returns a canonical form of `url` suitable for comparing two URIs + # that are meant to identify the same protected resource: lowercased scheme/host, + # default port stripped, fragment removed, percent-encoded dot octets normalized + # to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per + # RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away. + # + # Userinfo is *dropped*. The MCP authorization spec sends the canonicalized URL + # on the wire as the RFC 8707 `resource` claim and surfaces it in error messages; + # both paths would leak `user:pass@` credentials to the authorization server and + # to log destinations if we preserved them. The MCP server URI does not legitimately + # carry userinfo, so dropping it is also a no-op for normal traffic. + # + # Decoding `%2e`/`%2E` *before* dot-segment resolution is what prevents + # an attacker-supplied URL like `https://srv.example.com/api/%2e%2e/mcp` from sneaking + # past the PRM `resource` check in `resource_covers?`. + def canonicalize_url(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + uri.scheme = uri.scheme.downcase if uri.scheme + uri.host = uri.host.downcase if uri.host + uri.port = nil if uri.port == uri.default_port + + path = uri.path.to_s.gsub(/%2[eE]/, ".") + uri.path = remove_dot_segments(path) + uri.path = "" if uri.path == "/" + + uri.query = normalize_query(uri.query) + + uri.to_s + end + + # Returns true when `url` is safe to use for OAuth communication per + # the MCP authorization spec's "Communication Security" requirement: + # `https` is always allowed, `http` is permitted only when the host is + # a loopback address (`localhost`, `127.0.0.0/8`, or `::1`). + # + # The loopback exception applies uniformly to every OAuth-related URL + # the SDK consumes (PRM URL, AS metadata URL, `authorization_servers` + # entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, + # the `redirect_uri`, and the MCP transport URL when `oauth:` is set). + # A strict reading of OAuth 2.1 reserves the loopback carve-out for + # `redirect_uri` only (per RFC 8252), but neither the Python nor + # the TypeScript MCP SDK enforces HTTPS on those endpoints either - + # and the official MCP conformance test suite drives its fixtures + # over `http://localhost` auth servers, so enforcing HTTPS for everything + # except `redirect_uri` would break local development out of the box and + # regress 16 conformance scenarios. Operators who run in production are + # expected to deploy real HTTPS endpoints; this helper does not enforce + # that at the SDK boundary. + # + # Rejects URLs that fail to parse, lack a host, or whose `http://` host is + # something like `127.attacker.com` or `foo.localhost`, + # which would otherwise pass a naive `start_with?("127.")` check. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security + def secure_url?(url) + return false if url.nil? || url.to_s.empty? + + uri = URI.parse(url.to_s) + return false if uri.host.nil? || uri.host.empty? + + scheme = uri.scheme&.downcase + return true if scheme == "https" + return loopback_host?(uri.host) if scheme == "http" + + false + rescue URI::InvalidURIError + false + end + + # Like `canonicalize_url` but also strips query string, fragment, and + # userinfo. This variant is used for identity comparison against + # the request URL Faraday actually sends, which differs from the value + # the caller passed in two ways: `Faraday::Connection#url_prefix` + # drops query parameters, and Faraday hoists `user:pass@` out of + # the URL into an `Authorization: Basic` header before the request goes + # out. Including userinfo here would (a) raise a false-positive + # `InsecureURLError` on any legitimate URL with credentials in + # the authority, and (b) leak `user:pass` through the resulting error + # message - both of which would defeat the bearer-token-protection + # purpose of the identity check. + def canonicalize_origin_and_path(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + uri.query = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + uri.scheme = uri.scheme.downcase if uri.scheme + uri.host = uri.host.downcase if uri.host + uri.port = nil if uri.port == uri.default_port + + path = uri.path.to_s.gsub(/%2[eE]/, ".") + uri.path = remove_dot_segments(path) + uri.path = "" if uri.path == "/" + + uri.to_s + end + + # Returns true when `prm` (a PRM `resource` URL) covers `server` + # (the MCP endpoint URL): same scheme/host/port, with PRM's path being + # a prefix of the server's path. When PRM also advertises a query + # string, the server's query MUST be identical to it + # (otherwise a hijacked PRM that advertises `?tenant=evil` would cover + # an MCP server at `?tenant=victim` and let the attacker mint + # a different tenant's token for the same origin + path). + # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier + # over the origin + path prefix and covers any server query. + # + # An empty query (`prm_url?` -- URI#query returns `""`) is NOT + # treated as wildcard: it represents the URI literally `<...>?`, + # which is distinct from "no query at all" and from any non-empty query, + # so it must match exactly. + # + # Both arguments must already be canonicalized. + def resource_covers?(prm:, server:) + prm_uri = URI.parse(prm) + server_uri = URI.parse(server) + return false unless prm_uri.scheme == server_uri.scheme && + prm_uri.host == server_uri.host && + prm_uri.port == server_uri.port + + prm_path = prm_uri.path.to_s + server_path = server_uri.path.to_s + prm_path = "" if prm_path == "/" + server_path = "" if server_path == "/" + path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/") + return false unless path_covers + + prm_query = prm_uri.query + return true if prm_query.nil? + + prm_query == server_uri.query + end + + private + + # Unescapes a `quoted-string` value's `quoted-pair` octets per + # RFC 7230 Section 3.2.6 (referenced from RFC 7235): `\` becomes ``. + # https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 + def unescape_quoted_pair(value) + value.gsub(/\\(.)/, '\1') + end + + # Recognizes the IPv4 loopback range (`127.0.0.0/8`), IPv6 loopback + # (`::1`, optionally bracketed by `URI.parse`), and the `localhost` + # hostname (matched exactly so that hostnames like `foo.localhost` or + # `127.attacker.com` are not treated as loopback). + IPV4_LOOPBACK_RANGE = IPAddr.new("127.0.0.0/8") + IPV6_LOOPBACK = IPAddr.new("::1") + private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK + + def loopback_host?(host) + return false if host.nil? || host.empty? + + normalized = host.downcase + return true if normalized == "localhost" + + ip_candidate = normalized.delete_prefix("[").delete_suffix("]") + address = parse_ip_address(ip_candidate) + return false unless address + + return IPV4_LOOPBACK_RANGE.include?(address) if address.ipv4? + return address == IPV6_LOOPBACK if address.ipv6? + + false + end + + def parse_ip_address(candidate) + IPAddr.new(candidate) + rescue IPAddr::Error + nil + end + + def base_url(uri) + port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : "" + "#{uri.scheme}://#{uri.host}#{port_part}" + end + + # Normalizes a URL query string so two URLs that are equivalent in + # OAuth identity terms compare as equal. This is required because + # Faraday transparently rewrites `env.url` before sending a request: + # + # - Parameters get sorted by name (`?b=2&a=1` -> `?a=1&b=2`). + # - Percent-encoded hex is uppercased (`?x=%2f` -> `?x=%2F`). + # - A trailing `?` with no parameters is dropped (`?` -> no query). + # - Same-name keys are collapsed so only the last value survives + # (`?a=1&a=2` -> `?a=2`). + # - Empty-name pairs and blank `&&` separators are dropped + # (`?=v` -> no query, `?&&a=1&&` -> `?a=1`). + # - Value-less keys (`?tenant`) and empty-value keys (`?tenant=`) + # are kept distinct -- Faraday preserves the `=` exactly as + # the caller passed it. `URI.decode_www_form` / `encode_www_form` + # would collapse both to `?tenant=`, so this function does + # the parsing by hand on `&`-separated segments. + # + # Without applying the same transformation to our snapshotted URL, + # the request-time URL guard would false-positive on every URL that + # falls under one of the rules above. + # + # Returns `nil` when the resulting query is empty + # (matching Faraday's drop-empty-query behavior). + def normalize_query(query) + return if query.nil? || query.empty? + + # Each segment becomes `[decoded_name, has_equals?, decoded_value_or_nil]`. + # `has_equals?` is what lets us preserve the `?key` vs `?key=` + # distinction that Faraday respects. + parsed = query.split("&").filter_map do |segment| + next if segment.empty? + + name_raw, separator, value_raw = segment.partition("=") + name = URI.decode_www_form_component(name_raw) + next if name.empty? + + has_equals = !separator.empty? + value = has_equals ? URI.decode_www_form_component(value_raw) : nil + [name, has_equals, value] + end + + # Keys ending in `[]` (`tenant[]`, `roles[]`) are Faraday's array + # notation: the encoder preserves every occurrence in input order + # instead of collapsing them. All other keys (`tenant`, `a[b]`, + # plain scalars) are collapsed with last-write-wins semantics. + # Separating the two avoids a false negative where a hijacked + # middleware drops an entry from a `?tenant[]=victim&tenant[]=...` + # URL and slips past the guard, and a false positive on + # a legitimate `?roles[]=a&roles[]=b` URL. + array_segments = [] + scalar_segments = {} + parsed.each do |name, has_equals, value| + if name.end_with?("[]") + array_segments << [name, has_equals, value] + else + scalar_segments[name] = [has_equals, value] + end + end + + scalar_entries = scalar_segments.map { |name, (has_equals, value)| [name, has_equals, value] } + combined = scalar_entries + array_segments + return if combined.empty? + + # Stable sort by name: array entries that share a name keep their + # original order, while scalar names are alphabetized to match + # Faraday's deterministic encoding order. + combined.each_with_index + .sort_by { |(name, _, _), index| [name, index] } + .map do |(name, has_equals, value), _index| + encoded_name = URI.encode_www_form_component(name) + if has_equals + "#{encoded_name}=#{URI.encode_www_form_component(value)}" + else + encoded_name + end + end.join("&") + end + + # Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input + # buffer one segment at a time, popping the previous output segment + # whenever a `..` is encountered, so that `/api/../mcp` collapses to + # `/mcp` and `/foo/./bar` collapses to `/foo/bar`. + # https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4 + def remove_dot_segments(path) + return path if path.nil? || path.empty? + + input = path.dup + output = +"" + until input.empty? + if input.start_with?("../") + input = input[3..] + elsif input.start_with?("./") + input = input[2..] + elsif input.start_with?("/./") + input = "/#{input[3..]}" + elsif input == "/." + input = "/" + elsif input.start_with?("/../") + input = "/#{input[4..]}" + output = remove_last_segment(output) + elsif input == "/.." + input = "/" + output = remove_last_segment(output) + elsif input == "." || input == ".." + input = "" + else + segment = input.match(%r{\A/?[^/]*})[0] + output << segment + input = input[segment.length..] + end + end + output + end + + def remove_last_segment(output) + idx = output.rindex("/") + return +"" if idx.nil? + + output[0...idx] + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb new file mode 100644 index 0000000..3708a48 --- /dev/null +++ b/lib/mcp/client/oauth/flow.rb @@ -0,0 +1,587 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" +require "uri" + +module MCP + class Client + module OAuth + # Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow. + # Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is + # `Provider`; this class consumes a Provider plus signal data extracted from + # the failing response (resource_metadata URL, scope challenge). + class Flow + class AuthorizationError < StandardError; end + + # Raised specifically when the token endpoint rejects a grant with + # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to + # distinguish "the stored refresh token is dead, discard it" from + # transient failures (network, 5xx, other RFC 6749 error codes) that + # should leave the refresh token intact. + class InvalidGrantError < AuthorizationError; end + + def initialize(provider:, http_client_factory: nil) + @provider = provider + @http_client_factory = http_client_factory || -> { default_http_client } + end + + # Runs the full discovery, registration, authorization, and token exchange flow. + # On success, persists tokens via the provider and returns `:authorized`. + def run!(server_url:, resource_metadata_url: nil, scope: nil) + # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very + # first thing we contact in the OAuth flow, so it has to clear the same + # Communication Security bar as the OAuth endpoints downstream. + if resource_metadata_url + ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + end + + prm = fetch_protected_resource_metadata( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + authorization_server = first_authorization_server(prm) + ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") + + # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on + # both the authorization and token requests. When PRM advertises a `resource`, + # it MUST identify the same MCP server we are talking to; otherwise we are + # being redirected to credentials minted for a different audience. + resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) + + as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server) + ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) + ensure_secure_endpoints!(as_metadata) + ensure_pkce_supported!(as_metadata) + + client_info = ensure_client_registered(as_metadata: as_metadata) + + effective_scope = resolve_scope(scope: scope, prm: prm) + pkce = PKCE.generate + state = SecureRandom.urlsafe_base64(32) + + authorization_url = build_authorization_url( + as_metadata: as_metadata, + client_id: client_info_required_value(client_info, "client_id"), + scope: effective_scope, + state: state, + code_challenge: pkce[:code_challenge], + resource: resource, + ) + + @provider.redirect_handler.call(authorization_url) + code, returned_state = Array(@provider.callback_handler.call) + raise AuthorizationError, "Authorization callback did not return an authorization code." unless code + + unless states_match?(returned_state, state) + raise AuthorizationError, "OAuth state mismatch (CSRF protection)." + end + + tokens = exchange_authorization_code( + as_metadata: as_metadata, + client_info: client_info, + code: code, + code_verifier: pkce[:code_verifier], + resource: resource, + ) + + @provider.save_tokens(tokens) + :authorized + end + + # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 + # Section 6). Re-discovers PRM and AS metadata so we always pick up a moved + # token endpoint, and re-runs the audience / issuer / security checks + # before talking to it. + # + # Returns `:refreshed` on success. Raises `AuthorizationError` when + # the provider has no refresh token, no client information, or when + # the token endpoint refuses the refresh request. + # https://www.rfc-editor.org/rfc/rfc6749#section-6 + def refresh!(server_url:, resource_metadata_url: nil) + refresh_token = read_token("refresh_token") + raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token + + client_info = @provider.client_information + unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") + raise AuthorizationError, "Cannot refresh: no client_information in provider storage." + end + + if resource_metadata_url + ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + end + + prm = fetch_protected_resource_metadata( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + authorization_server = first_authorization_server(prm) + ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") + + resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) + + as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server) + ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) + ensure_secure_endpoints!(as_metadata) + + new_tokens = exchange_refresh_token( + as_metadata: as_metadata, + client_info: client_info, + refresh_token: refresh_token, + resource: resource, + ) + + @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token)) + :refreshed + end + + private + + def read_token(key) + tokens = @provider.tokens + return unless tokens.is_a?(Hash) + + value = tokens[key] || tokens[key.to_sym] + value.to_s.empty? ? nil : value + end + + # Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in + # which case the previous one stays valid. Preserve it explicitly so + # downstream refresh attempts still work. + def preserve_refresh_token(new_tokens, previous_refresh_token) + return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token] + + new_tokens.merge("refresh_token" => previous_refresh_token) + end + + def fetch_protected_resource_metadata(server_url:, resource_metadata_url:) + urls = Discovery.protected_resource_metadata_urls( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + fetch_metadata_json(urls, label: "protected resource metadata") + end + + def fetch_authorization_server_metadata(issuer_url:) + urls = Discovery.authorization_server_metadata_urls(issuer_url) + fetch_metadata_json(urls, label: "authorization server metadata") + end + + # Reads `authorization_servers` from a PRM document and returns + # the first entry, raising `AuthorizationError` for any of the malformed + # shapes a non-compliant server could emit (missing field, non-Array + # value, empty Array, non-String first entry). Centralizing this lets + # both the full flow and the refresh flow share the same defensive + # parse instead of each one duplicating a Hash-and-Array check. + def first_authorization_server(prm) + authorization_servers = prm["authorization_servers"] + unless authorization_servers.is_a?(Array) + raise AuthorizationError, + "Protected resource metadata `authorization_servers` is not an array " \ + "(got #{authorization_servers.class})." + end + + if authorization_servers.empty? + raise AuthorizationError, "Protected resource metadata has no authorization_servers." + end + + first = authorization_servers.first + unless first.is_a?(String) && !first.empty? + raise AuthorizationError, + "Protected resource metadata `authorization_servers[0]` is not a non-empty string." + end + + first + end + + # Walks candidate metadata URLs and returns the parsed JSON body of + # the first 2xx response. Raises `AuthorizationError` for transport + # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`) + # so callers do not have to handle raw Faraday/JSON exceptions. + def fetch_metadata_json(urls, label:) + last_error = nil + urls.each do |url| + response = begin + http_get(url) + rescue Faraday::Error => e + last_error = "GET #{url} raised #{e.class}: #{e.message}" + next + end + + if response.status >= 200 && response.status < 300 + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}." + end + + # Even valid JSON can be the wrong shape (a top-level array, + # a bare `null`, a string, ...). The discovery callers index by + # name (`prm["authorization_servers"]`, etc.), so anything that + # is not a Hash would raise `TypeError` / `NoMethodError` + # downstream. Surface that as `AuthorizationError` instead so + # callers see a single, documented error type. + unless parsed.is_a?(Hash) + raise AuthorizationError, + "#{label} from #{url} is not a JSON object (got #{parsed.class})." + end + + return parsed + end + + last_error = "GET #{url} returned #{response.status}" + end + raise AuthorizationError, "Failed to fetch #{label}: #{last_error}." + end + + def ensure_pkce_supported!(as_metadata) + methods = as_metadata["code_challenge_methods_supported"] + return if methods.is_a?(Array) && methods.include?("S256") + + raise AuthorizationError, + "Authorization server does not advertise S256 PKCE support; refusing to proceed." + end + + # Per the MCP authorization spec's Communication Security requirement, + # OAuth endpoints MUST use HTTPS unless the host is a loopback address. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security + def ensure_secure_url!(url, label:) + return if Discovery.secure_url?(url) + + raise AuthorizationError, + "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)." + end + + def ensure_secure_endpoints!(as_metadata) + ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key| + endpoint = as_metadata[key] + ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint + end + end + + # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be + # identical (literal byte-for-byte equality, no normalization) to + # the issuer URL the client used to discover that document. This guards + # against a CDN/relay returning the metadata of a *different* + # authorization server than the one PRM advertised, and against + # ambiguities like trailing `/`, fragments, or case differences that + # could mask a confused-deputy attempt. + # https://www.rfc-editor.org/rfc/rfc8414#section-3.3 + def ensure_issuer_matches!(expected:, returned:) + unless returned + raise AuthorizationError, "Authorization server metadata is missing the `issuer` field." + end + + return if expected.to_s == returned.to_s + + raise AuthorizationError, + "Authorization server metadata `issuer` does not match the discovery URL " \ + "(expected #{expected.inspect}, got #{returned.inspect})." + end + + def ensure_client_registered(as_metadata:) + existing = @provider.client_information + return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id") + + registration_endpoint = as_metadata["registration_endpoint"] + unless registration_endpoint + raise AuthorizationError, + "Authorization server has no registration_endpoint and no pre-registered client information was provided." + end + + response = begin + http_post_json(registration_endpoint, @provider.client_metadata) + rescue Faraday::Error => e + raise AuthorizationError, + "Dynamic client registration failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + raise AuthorizationError, "Dynamic client registration failed with status #{response.status}." + end + + info = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, + "Failed to parse dynamic client registration response: #{e.message}." + end + + unless info.is_a?(Hash) && client_info_required_value(info, "client_id") + raise AuthorizationError, + "Dynamic client registration response is missing `client_id`." + end + + @provider.save_client_information(info) + info + end + + # Reads `key` from a `client_information` hash that may use either string or + # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built + # `{ client_id:, client_secret: }` and have both work. + def client_info_value(info, key) + info[key] || info[key.to_sym] + end + + # Same as `client_info_value` but treats blank strings (`""` or only + # whitespace) as absent. Used for fields where empty values are never + # meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`) + # and would otherwise let a misbehaving AS or hand-built + # `client_information` short-circuit the "is the client registered?" + # check, or send a literal `client_secret: " "` to the token endpoint. + def client_info_required_value(info, key) + value = client_info_value(info, key) + return if value.nil? + return if value.is_a?(String) && value.strip.empty? + + value + end + + # Returns the canonical RFC 8707 `resource` URI to send on authorization and + # token requests. When PRM advertises `resource`, that value is + # the authorization server's idea of the resource identifier and is preferred. + # When PRM omits it, the canonicalized MCP server URL is used. + # + # Either way, we validate that PRM's `resource` covers the MCP server URL + # the client is actually talking to (same origin, with PRM's path as a prefix of + # the server URL's path) to prevent a malicious or misconfigured PRM from + # redirecting credentials to a different audience. + def canonical_resource(server_url:, prm_resource:) + server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL") + return server_canonical unless prm_resource + + prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`") + unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical) + raise AuthorizationError, + "Protected resource metadata `resource` does not match the MCP server URL " \ + "(server=#{server_canonical}, prm=#{prm_canonical})." + end + + prm_canonical + end + + # Wraps `Discovery.canonicalize_url` so that any URI parsing failure + # caused by malformed input from the server (`PRM.resource`, AS metadata + # endpoints, ...) surfaces as `AuthorizationError` instead of leaking + # a raw `URI::InvalidURIError` / `ArgumentError`. + def safe_canonicalize_url(url, label:) + Discovery.canonicalize_url(url) + rescue URI::InvalidURIError, ArgumentError => e + raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}." + end + + # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery + # of the expected value. + # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7 + # (the project's minimum supported version). + # The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time + # does not leak the position of the first mismatching byte. + def states_match?(returned, expected) + returned = returned.to_s + return false unless returned.bytesize == expected.bytesize + + result = 0 + returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b } + result.zero? + end + + # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution + # prefers the `WWW-Authenticate` challenge first, then `scopes_supported` + # from the Protected Resource Metadata, and falls back to a provider-supplied + # scope only if both are absent. The provider-supplied scope must not pre-empt + # a server-advertised one. + def resolve_scope(scope:, prm:) + return scope if scope && !scope.empty? + + supported = prm["scopes_supported"] + return supported.join(" ") if supported.is_a?(Array) && !supported.empty? + + return @provider.scope if @provider.scope && !@provider.scope.empty? + + nil + end + + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) + authorization_endpoint = as_metadata["authorization_endpoint"] + unless authorization_endpoint + raise AuthorizationError, + "Authorization server metadata is missing `authorization_endpoint`." + end + + uri = begin + URI.parse(authorization_endpoint) + rescue URI::InvalidURIError => e + raise AuthorizationError, + "Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}." + end + + params = URI.decode_www_form(uri.query.to_s) + params << ["response_type", "code"] + params << ["client_id", client_id] + params << ["redirect_uri", @provider.redirect_uri] + params << ["code_challenge", code_challenge] + params << ["code_challenge_method", "S256"] + params << ["state", state] + params << ["scope", scope] if scope + params << ["resource", resource] if resource + uri.query = URI.encode_www_form(params) + uri + end + + def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:) + form = { + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => @provider.redirect_uri, + "code_verifier" => code_verifier, + } + form["resource"] = resource if resource + + post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + end + + def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:) + form = { + "grant_type" => "refresh_token", + "refresh_token" => refresh_token, + } + form["resource"] = resource if resource + + post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + end + + # Submits a form-encoded request to the token endpoint, applying + # the client authentication method advertised in `client_information` and + # adding `client_id` (and `client_secret` when not using HTTP Basic). + def post_to_token_endpoint(as_metadata:, client_info:, form:) + client_id = client_info_required_value(client_info, "client_id") + unless client_id + raise AuthorizationError, + "Cannot post to token endpoint: client_information is missing `client_id`." + end + + client_secret = client_info_required_value(client_info, "client_secret") + token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") + + form = form.merge("client_id" => client_id) + headers = {} + if client_secret + case token_endpoint_auth_method + when "client_secret_post" + form["client_secret"] = client_secret + when "none" + # Public client; no credential. + else + # RFC 6749 §2.3.1 recommends Basic for confidential clients and + # both Python and TypeScript SDKs default here when + # the authentication method is not explicitly stored. + headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret) + end + end + + token_endpoint = as_metadata["token_endpoint"] + unless token_endpoint + raise AuthorizationError, + "Authorization server metadata is missing `token_endpoint`." + end + + response = begin + http_post_form(token_endpoint, form, headers: headers) + rescue Faraday::Error => e + raise AuthorizationError, + "Token request to #{token_endpoint} failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + if token_endpoint_error_code(response) == "invalid_grant" + raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant." + end + + raise AuthorizationError, "Token endpoint returned status #{response.status}." + end + + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}." + end + + # Token responses MUST be a JSON object per RFC 6749 §5.1. Anything + # else (`null`, `[]`, a bare string) would otherwise be persisted + # as `provider.tokens` and raise raw `NoMethodError` / `TypeError` + # the next time `provider.access_token` is read. + unless parsed.is_a?(Hash) + raise AuthorizationError, + "Token endpoint response is not a JSON object (got #{parsed.class})." + end + + parsed + end + + # Extracts the `error` code from an RFC 6749 §5.2 error response body + # when one is parseable. Returns nil on any parse failure or when + # the body is not JSON. + def token_endpoint_error_code(response) + body = response_body_string(response).to_s + return if body.empty? + + parsed = JSON.parse(body) + parsed["error"] if parsed.is_a?(Hash) + rescue JSON::ParserError + nil + end + + # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be + # `application/x-www-form-urlencoded` encoded before they are joined with + # `:` and base64-encoded for the `Authorization: Basic` header. This is + # what prevents credentials containing `:` or other special characters + # from being mis-parsed by the authorization server. + # https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1 + def basic_auth_credentials(client_id, client_secret) + encoded_id = URI.encode_www_form_component(client_id) + encoded_secret = URI.encode_www_form_component(client_secret) + Base64.strict_encode64("#{encoded_id}:#{encoded_secret}") + end + + def http_get(url) + http_client.get(url) + end + + def http_post_json(url, body) + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/json" + req.headers["Accept"] = "application/json" + req.body = JSON.generate(body) + end + end + + def http_post_form(url, form, headers: {}) + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + headers.each { |key, value| req.headers[key] = value } + req.body = URI.encode_www_form(form) + end + end + + def http_client + @http_client ||= @http_client_factory.call + end + + def default_http_client + require "faraday" + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + end + end + + def response_body_string(response) + body = response.body + body.is_a?(String) ? body : body.to_s + end + end + end + end +end diff --git a/lib/mcp/client/oauth/in_memory_storage.rb b/lib/mcp/client/oauth/in_memory_storage.rb new file mode 100644 index 0000000..83b261b --- /dev/null +++ b/lib/mcp/client/oauth/in_memory_storage.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Default reference implementation of the storage contract that + # `Provider` uses to persist OAuth state. Holds the two pieces of data + # the flow saves and reads on every request: + # + # - `tokens`: the hash returned by the token endpoint + # (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.). + # - `client_information`: the hash returned by Dynamic Client Registration + # or supplied as pre-registered credentials + # (`client_id`, optional `client_secret`, optional + # `token_endpoint_auth_method`). + # + # This class keeps everything in process memory, so the credentials live + # only for the lifetime of the Ruby process. Applications that need + # persistence across restarts should supply a custom object responding to + # the same four-method contract (`tokens`, `save_tokens(t)`, + # `client_information`, `save_client_information(info)`) and pass it via + # `Provider.new(storage: ...)`. The shape mirrors Python SDK's + # `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls + # the same responsibilities into a single object. + class InMemoryStorage + attr_accessor :tokens, :client_information + + def initialize + @tokens = nil + @client_information = nil + end + + def save_tokens(tokens) + @tokens = tokens + end + + def save_client_information(info) + @client_information = info + end + end + end + end +end diff --git a/lib/mcp/client/oauth/pkce.rb b/lib/mcp/client/oauth/pkce.rb new file mode 100644 index 0000000..63df4ff --- /dev/null +++ b/lib/mcp/client/oauth/pkce.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "base64" +require "digest" +require "securerandom" + +module MCP + class Client + module OAuth + # Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to + # bind an OAuth 2.1 authorization request to the same client that later + # redeems the resulting authorization code. Without PKCE, an attacker + # who steals an authorization code in transit (e.g. through a logged redirect URI) + # can exchange it for an access token; with PKCE, the attacker would also need + # the per-request `code_verifier`, which is never sent to the browser or any intermediary. + # + # MCP authorization mandates the `S256` method, so this module always returns + # that method and refuses to expose `plain` as an option. + # + # The module is stateless: callers ask for a fresh pair with `generate` for + # every authorization request and discard the result once the token endpoint has accepted it. + # + # - https://datatracker.ietf.org/doc/html/rfc7636 + # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection + module PKCE + class << self + # Generates a PKCE pair (code_verifier and S256 code_challenge). + def generate + verifier = SecureRandom.urlsafe_base64(64) + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + + { code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" } + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb new file mode 100644 index 0000000..211f496 --- /dev/null +++ b/lib/mcp/client/oauth/provider.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via + # the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK + # and httpx.Auth-based provider in the Python SDK. + # + # Required keyword arguments: + # - `client_metadata` - Hash sent to the authorization server's Dynamic Client + # Registration endpoint. Must include at minimum `redirect_uris`, + # `grant_types`, `response_types`, and `token_endpoint_auth_method`. + # - `redirect_uri` - String: the redirect URI used for the authorization + # request. Must be one of `redirect_uris` in `client_metadata`. + # - `redirect_handler` - Callable invoked with the fully-built authorization + # URL (a `URI`). Implementations typically open the user's browser. + # - `callback_handler` - Callable invoked after `redirect_handler`. Returns + # `[code, state]` where `code` is the authorization code and `state` is + # the `state` parameter received on the redirect URI. + # + # Optional keyword arguments: + # - `scope` - String of space-separated scopes to request when the server's + # `WWW-Authenticate` does not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, + # `client_information`, and `save_client_information(info)`. Defaults to + # an `InMemoryStorage`. + class Provider + # Raised when `Provider#initialize` is called with a `redirect_uri` that + # is neither HTTPS nor a loopback `http://` URL, per the MCP + # authorization spec's Communication Security requirement. + class InsecureRedirectURIError < ArgumentError; end + + # Raised when the `redirect_uri` argument is not listed in + # `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering + # the URI with the authorization server but then sending a different + # one with the authorization request would be rejected by the AS at + # runtime; failing at construction surfaces the bug earlier. + class UnregisteredRedirectURIError < ArgumentError; end + + attr_reader :client_metadata, + :redirect_uri, + :scope, + :storage, + :redirect_handler, + :callback_handler + + def initialize( + client_metadata:, + redirect_uri:, + redirect_handler:, + callback_handler:, + scope: nil, + storage: nil + ) + unless Discovery.secure_url?(redirect_uri) + raise InsecureRedirectURIError, + "redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \ + "(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement." + end + + registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"]) + unless registered.include?(redirect_uri) + raise UnregisteredRedirectURIError, + "redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \ + "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request." + end + + @client_metadata = client_metadata + @redirect_uri = redirect_uri + @redirect_handler = redirect_handler + @callback_handler = callback_handler + @scope = scope + @storage = storage || InMemoryStorage.new + end + + def access_token + tokens&.dig("access_token") || tokens&.dig(:access_token) + end + + def tokens + @storage.tokens + end + + def save_tokens(tokens) + @storage.save_tokens(tokens) + end + + def client_information + @storage.client_information + end + + def save_client_information(info) + @storage.save_client_information(info) + end + + def clear_tokens! + @storage.save_tokens(nil) + end + end + end + end +end diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb new file mode 100644 index 0000000..018957e --- /dev/null +++ b/test/mcp/client/oauth/discovery_test.rb @@ -0,0 +1,467 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Client + module OAuth + class DiscoveryTest < Minitest::Test + def test_parse_www_authenticate_with_quoted_resource_metadata + header = 'Bearer error="invalid_token", error_description="Missing", ' \ + 'resource_metadata="https://srv.example.com/.well-known/oauth-protected-resource/mcp"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("Missing", params["error_description"]) + assert_equal("https://srv.example.com/.well-known/oauth-protected-resource/mcp", params["resource_metadata"]) + end + + def test_parse_www_authenticate_returns_empty_for_other_schemes + assert_empty(Discovery.parse_www_authenticate('Basic realm="x"')) + end + + def test_parse_www_authenticate_returns_empty_for_nil + assert_empty(Discovery.parse_www_authenticate(nil)) + end + + def test_parse_www_authenticate_finds_bearer_after_other_scheme + header = 'Basic realm="x", Bearer error="invalid_token", resource_metadata="https://srv/x"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_stops_at_following_scheme + header = 'Bearer error="invalid_token", resource_metadata="https://srv/x", DPoP algs="ES256"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + refute(params.key?("algs")) + end + + def test_parse_www_authenticate_returns_empty_when_bearer_absent + assert_empty(Discovery.parse_www_authenticate('DPoP algs="ES256"')) + end + + def test_parse_www_authenticate_handles_bare_bearer + assert_empty(Discovery.parse_www_authenticate("Bearer")) + end + + def test_parse_www_authenticate_handles_tab_separator + params = Discovery.parse_www_authenticate(%(Bearer\terror="invalid_token",\tresource_metadata="https://srv/x")) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_tolerates_trailing_comma + params = Discovery.parse_www_authenticate('Bearer error="invalid_token", resource_metadata="https://srv/x",') + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_handles_quoted_value_with_comma_and_spaces + params = Discovery.parse_www_authenticate('Bearer error_description="missing, expired, or revoked token"') + + assert_equal("missing, expired, or revoked token", params["error_description"]) + end + + def test_parse_www_authenticate_unescapes_quoted_pair + # Per RFC 7230 Section 3.2.6, a backslash inside a quoted-string escapes + # the next character. The parser must surface the unescaped form. + header = 'Bearer error_description="value with \"quoted\" word and a back\\\\slash"' + params = Discovery.parse_www_authenticate(header) + + assert_equal('value with "quoted" word and a back\\slash', params["error_description"]) + end + + def test_protected_resource_metadata_urls_uses_explicit_url_first + urls = Discovery.protected_resource_metadata_urls( + server_url: "https://api.example.com/mcp", + resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ) + + assert_equal("https://api.example.com/.well-known/oauth-protected-resource/mcp", urls.first) + assert_includes(urls, "https://api.example.com/.well-known/oauth-protected-resource") + end + + def test_protected_resource_metadata_urls_falls_back_to_path_then_root + urls = Discovery.protected_resource_metadata_urls(server_url: "https://api.example.com/mcp") + + assert_equal( + [ + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + "https://api.example.com/.well-known/oauth-protected-resource", + ], + urls, + ) + end + + def test_authorization_server_metadata_urls_for_root_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + + assert_equal( + [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ], + urls, + ) + end + + def test_authorization_server_metadata_urls_for_path_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + assert_includes(urls, "https://auth.example.com/.well-known/oauth-authorization-server/tenant1") + assert_includes(urls, "https://auth.example.com/.well-known/openid-configuration/tenant1") + assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration") + end + + def test_canonicalize_url_normalizes_scheme_host_port_and_path + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("HTTPS://Srv.Example.COM:443/mcp#frag"), + ) + assert_equal( + "http://srv.example.com", + Discovery.canonicalize_url("http://Srv.Example.COM:80/"), + ) + assert_equal( + "https://srv.example.com:8443/mcp", + Discovery.canonicalize_url("https://srv.example.com:8443/mcp"), + ) + end + + def test_canonicalize_url_resolves_dot_segments + # Critical for audience binding: an attacker-supplied URL like + # `/api/../mcp` must collapse to `/mcp` so it cannot pass a PRM check + # whose `resource` is the more privileged `/api` path. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/../mcp"), + ) + assert_equal( + "https://srv.example.com/api/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/./mcp"), + ) + assert_equal( + "https://srv.example.com/bar", + Discovery.canonicalize_url("https://srv.example.com/foo/../../bar"), + ) + assert_equal( + "https://srv.example.com", + Discovery.canonicalize_url("https://srv.example.com/api/.."), + ) + end + + def test_canonicalize_url_resolves_percent_encoded_dot_segments + # `%2e` and `%2E` are percent-encoded forms of `.` (RFC 3986 Section 6.2.2.2) + # and must be decoded *before* dot-segment resolution. Otherwise, + # an attacker-supplied URL like `/api/%2e%2e/mcp` would slip past + # `remove_dot_segments` and escape the PRM `resource=/api` audience + # binding. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2E%2E/mcp"), + ) + assert_equal( + "https://srv.example.com/api/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2e/mcp"), + ) + end + + def test_canonicalize_url_drops_userinfo + # The canonicalized URL is sent on the wire as the RFC 8707 `resource` + # claim and is surfaced in error messages, so credentials in + # the authority must never round-trip: dropping `user:pass@` keeps them + # from leaking to the authorization server or to log destinations. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://user:pass@Srv.Example.COM:443/api/../mcp"), + ) + end + + def test_canonicalize_url_normalizes_query_to_match_faraday + # Faraday rewrites `env.url` before sending a request: it sorts + # parameters by name, uppercases percent-encoded hex, and drops + # a bare `?` with no parameters. The canonical form must apply + # the same transformation so the URL guard does not raise a false + # positive on every valid request with a query string. + assert_equal( + "https://srv.example.com/mcp?a=1&b=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?b=2&a=1"), + ) + assert_equal( + "https://srv.example.com/mcp?x=%2F", + Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?"), + ) + end + + def test_canonicalize_url_collapses_same_name_query_parameters + # Faraday's internal query encoder uses a Hash, so repeated keys + # collapse to the last value. The canonical form must apply + # the same collapse, otherwise the URL guard false-positives on URLs + # like `?a=1&a=2` (Faraday rewrites to `?a=2`). + assert_equal( + "https://srv.example.com/mcp?a=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&a=2"), + ) + assert_equal( + "https://srv.example.com/mcp?a=3&b=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b=2&a=3"), + ) + assert_equal( + "https://srv.example.com/mcp?x=%2F", + Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f&x=%2F"), + ) + end + + def test_canonicalize_url_preserves_valueless_vs_empty_value_distinction + # Faraday preserves the difference between `?key` (no `=`) and + # `?key=` (empty value) exactly as the caller passed it. A naive + # `URI.decode_www_form` round-trip would collapse both to `?key=` + # and false-positive the URL guard / RFC 8707 `resource` check for + # any URL that uses the value-less form. + assert_equal( + "https://srv.example.com/mcp?tenant", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant"), + ) + assert_equal( + "https://srv.example.com/mcp?tenant=", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant="), + ) + assert_equal( + "https://srv.example.com/mcp?a=1&b", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b"), + ) + # Collapse with last-write-wins also keeps the `=` form of + # the surviving segment: `flag=1&flag` -> last is `flag` (no `=`), so + # the canonical form is `?flag`, matching Faraday. + assert_equal( + "https://srv.example.com/mcp?flag", + Discovery.canonicalize_url("https://srv.example.com/mcp?flag=1&flag"), + ) + end + + def test_canonicalize_url_preserves_array_form_repeated_keys + # Keys ending in `[]` are Faraday's array notation: the encoder + # preserves every occurrence in input order instead of collapsing + # them. The canonical form must do the same; otherwise a hijacked + # middleware that drops one entry from `?tenant[]=victim&tenant[]=...` + # would slip past the URL guard, and a legitimate + # `?roles[]=a&roles[]=b` URL would false-positive on the first + # request. + assert_equal( + "https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2"), + ) + assert_equal( + "https://srv.example.com/mcp?tenant%5B%5D=evil&tenant%5B%5D=victim", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant[]=evil&tenant[]=victim"), + ) + assert_equal( + "https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2&b=3", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2&b=3"), + ) + # Nested keys like `a[b]` are not array notation. They collapse with + # last-write-wins, matching Faraday's `NestedParamsEncoder`. + assert_equal( + "https://srv.example.com/mcp?a%5Bb%5D=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[b]=1&a[b]=2"), + ) + end + + def test_canonicalize_url_drops_empty_name_and_blank_query_segments + # Faraday's encoder treats pairs with an empty name and blank `&&` + # separators as no parameter at all. The canonical form must do + # the same; otherwise URLs like `?=v` or `?&&a=1&&` produce a snapshot + # that disagrees with the Faraday-rewritten effective URL and + # the request-time guard false-positives. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?=v"), + ) + assert_equal( + "https://srv.example.com/mcp?a=1", + Discovery.canonicalize_url("https://srv.example.com/mcp?&&a=1&&"), + ) + assert_equal( + "https://srv.example.com/mcp?a=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&&a=2"), + ) + assert_equal( + "https://srv.example.com/mcp?a=1", + Discovery.canonicalize_url("https://srv.example.com/mcp?=&=&a=1"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?&"), + ) + end + + def test_resource_covers_blocks_dot_segment_bypass_after_canonicalization + server = Discovery.canonicalize_url("https://srv.example.com/api/../mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/api") + + refute( + Discovery.resource_covers?(prm: prm, server: server), + "PRM `resource=/api` must not cover `/api/../mcp` after canonicalization", + ) + end + + def test_secure_url_accepts_https + assert(Discovery.secure_url?("https://srv.example.com/mcp")) + assert(Discovery.secure_url?("HTTPS://Srv.Example.COM/mcp")) + end + + def test_secure_url_accepts_loopback_http + assert(Discovery.secure_url?("http://localhost/x")) + assert(Discovery.secure_url?("http://localhost:8080/x")) + assert(Discovery.secure_url?("http://127.0.0.1/x")) + assert(Discovery.secure_url?("http://127.42.0.5/x")) + assert(Discovery.secure_url?("http://127.255.255.255/x")) + assert(Discovery.secure_url?("http://[::1]/x")) + end + + def test_secure_url_rejects_non_loopback_http + refute(Discovery.secure_url?("http://srv.example.com/mcp")) + refute(Discovery.secure_url?("http://192.168.1.1/x")) + refute(Discovery.secure_url?("http://10.0.0.1/x")) + end + + def test_secure_url_rejects_hostname_tricks_that_resemble_loopback + # Naive `start_with?("127.")` would let these slip through; IPAddr-based + # checks correctly classify them as non-loopback hostnames. + refute(Discovery.secure_url?("http://127.attacker.com/x")) + refute(Discovery.secure_url?("http://127.0.0.1.evil.com/x")) + refute(Discovery.secure_url?("http://127./x")) + # `localhost` MUST be matched exactly - `foo.localhost` is just a regular + # hostname that happens to share the suffix. + refute(Discovery.secure_url?("http://foo.localhost/x")) + end + + def test_secure_url_rejects_non_http_schemes + refute(Discovery.secure_url?("ftp://srv.example.com/mcp")) + refute(Discovery.secure_url?("file:///etc/passwd")) + refute(Discovery.secure_url?("javascript:alert(1)")) + refute(Discovery.secure_url?("")) + refute(Discovery.secure_url?(nil)) + end + + def test_secure_url_rejects_malformed_or_hostless_urls + refute(Discovery.secure_url?("https:/missing-host")) + refute(Discovery.secure_url?("https:///missing-host")) + refute(Discovery.secure_url?("https://")) + refute(Discovery.secure_url?("not a url")) + end + + def test_resource_covers_blocks_percent_encoded_dot_segment_bypass + server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/api") + + refute( + Discovery.resource_covers?(prm: prm, server: server), + "PRM `resource=/api` must not cover `/api/%2e%2e/mcp` after canonicalization", + ) + end + + def test_resource_covers_blocks_cross_tenant_query_bypass + # An attacker-controlled PRM that advertises a different tenant's + # resource URI MUST NOT be treated as covering the server URL. If it + # were, the client would mint a token for `tenant=evil` while + # actually talking to `tenant=victim`, defeating audience binding. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm_evil = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil") + + refute( + Discovery.resource_covers?(prm: prm_evil, server: server), + "PRM `resource=?tenant=evil` must not cover server `?tenant=victim`", + ) + end + + def test_resource_covers_allows_matching_query + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + + assert(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_allows_prm_without_query_to_cover_server_with_query + # A query-less PRM acts as a generic identifier over the origin + + # path prefix and is allowed to cover server URLs that scope by query. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=1") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp") + + assert(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_blocks_prm_with_query_when_server_has_none + # PRM that names a specific tenant cannot cover an unscoped server + # URL; otherwise a hijacked PRM could re-bind the token to a tenant + # the user did not select. + server = Discovery.canonicalize_url("https://srv.example.com/mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil") + + refute(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_does_not_treat_empty_query_as_wildcard + # `https://srv.example.com/mcp?` parses with `URI#query == ""`, which + # is distinct from "no query at all" (`URI#query == nil`). A naive + # `prm_query.nil? || prm_query.empty?` would let an attacker-supplied + # PRM with a trailing `?` slip past the cross-tenant audience check. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm_empty_query = "https://srv.example.com/mcp?" + + refute( + Discovery.resource_covers?(prm: prm_empty_query, server: server), + "PRM with a literal empty query (`?`) must not act as a wildcard cover", + ) + end + + def test_resource_covers_returns_true_for_same_origin_prefix + assert(Discovery.resource_covers?( + prm: "https://srv.example.com", + server: "https://srv.example.com/mcp", + )) + assert(Discovery.resource_covers?( + prm: "https://srv.example.com/mcp", + server: "https://srv.example.com/mcp", + )) + assert(Discovery.resource_covers?( + prm: "https://srv.example.com/api", + server: "https://srv.example.com/api/v2/mcp", + )) + end + + def test_resource_covers_returns_false_for_different_origin_or_path_mismatch + refute(Discovery.resource_covers?( + prm: "https://evil.example.com/mcp", + server: "https://srv.example.com/mcp", + )) + refute(Discovery.resource_covers?( + prm: "https://srv.example.com:8443/mcp", + server: "https://srv.example.com/mcp", + )) + # Path "api" must not be considered a prefix of "/apiv2". + refute(Discovery.resource_covers?( + prm: "https://srv.example.com/api", + server: "https://srv.example.com/apiv2", + )) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb new file mode 100644 index 0000000..5da4ea8 --- /dev/null +++ b/test/mcp/client/oauth/flow_test.rb @@ -0,0 +1,1318 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "json" +require "webmock/minitest" +require "faraday" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class FlowTest < Minitest::Test + def setup + WebMock.enable! + @server_url = "https://srv.example.com/mcp" + @prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp" + @auth_base = "https://auth.example.com" + @as_metadata_url = "#{@auth_base}/.well-known/oauth-authorization-server" + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client", client_name: "ruby-sdk-test"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return do |req| + form = URI.decode_www_form(req.body).to_h + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "test-token-from-flow", + token_type: "Bearer", + expires_in: 3600, + grant_type_received: form["grant_type"], + code_verifier_received: form["code_verifier"], + ), + } + end + end + + def teardown + WebMock.reset! + end + + def test_run_completes_full_authorization_flow + captured_authorization_url = nil + state_value = nil + + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_value = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + flow = Flow.new(provider: provider) + result = flow.run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("code", query["response_type"]) + assert_equal("test-client", query["client_id"]) + assert_equal("S256", query["code_challenge_method"]) + assert_equal("http://localhost:0/callback", query["redirect_uri"]) + assert_equal("https://srv.example.com/mcp", query["resource"]) + + assert_requested(:get, @prm_url) + assert_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" && + !form["code_verifier"].to_s.empty? && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_raises_on_state_mismatch + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "wrong-state"] }, + ) + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_run_raises_when_pkce_unsupported + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["plain"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/S256/, error.message) + end + + def test_run_raises_when_authorization_endpoint_is_missing + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + # NB: no `authorization_endpoint`. + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_endpoint/i, error.message) + end + + def test_run_raises_when_authorization_endpoint_is_malformed_uri + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "ht!tp:bad uri", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_endpoint/i, error.message) + end + + def test_run_raises_when_prm_resource_is_malformed_uri + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "ht!tp:bad uri", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/PRM `resource`|not a valid URI/i, error.message) + end + + def test_run_raises_when_prm_is_not_a_json_object + # Valid JSON but the wrong shape: indexing into a top-level array + # would otherwise raise `TypeError` and leak out of the SDK. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "[]", + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/not a JSON object/i, error.message) + end + + def test_run_raises_when_prm_authorization_servers_is_not_an_array + # `authorization_servers` MUST be an Array per RFC 9728. A misbehaving + # PRM that returns a String would otherwise reach `.first` and raise + # raw `NoMethodError` out of the SDK. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: "https://auth.example.com", + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_servers/i, error.message) + end + + def test_run_raises_when_token_response_is_not_a_json_object + # The token endpoint MUST return a JSON object per RFC 6749 §5.1. + # A non-object body would otherwise be persisted into the provider + # and raise raw exceptions the next time `provider.access_token` + # is read. + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "[]", + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/Token endpoint response is not a JSON object/i, error.message) + end + + def test_run_treats_whitespace_only_stored_client_id_as_unregistered + # `" "` (whitespace only) is just as meaningless as `""`. + # The check on stored client_information must trigger DCR rather than + # passing the blank value through to the token endpoint. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "registered-after-whitespace"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => " ") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("registered-after-whitespace", provider.client_information.fetch("client_id")) + end + + def test_run_raises_when_as_metadata_is_not_a_json_object + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "null", + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/not a JSON object/i, error.message) + end + + def test_run_raises_when_dcr_response_has_blank_client_id + # A blank `client_id` is just as invalid as a missing one: peer SDKs + # (TS / Python) require a non-empty string, and treating `""` as + # truthy would let a misbehaving AS register a "client" the SDK can + # never authenticate as. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "", client_name: "blank-id"), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/client_id/i, error.message) + end + + def test_run_treats_blank_stored_client_id_as_unregistered + # Pre-stored `client_information` with a blank `client_id` must + # trigger DCR rather than skipping registration; otherwise + # the token endpoint receives `client_id=""` which the AS will reject. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "registered-after-blank"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("registered-after-blank", provider.client_information.fetch("client_id")) + end + + def test_run_raises_when_dcr_response_lacks_client_id + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_name: "test-but-no-id"), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/client_id/i, error.message) + end + + def test_run_skips_dcr_when_client_information_already_stored + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + def test_run_accepts_symbol_keys_in_preregistered_client_information + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information(client_id: "preregistered-symbol") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + def test_run_uses_basic_auth_when_token_endpoint_auth_method_is_client_secret_basic + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information( + "client_id" => "pre-registered-client", + "client_secret" => "pre-registered-secret", + "token_endpoint_auth_method" => "client_secret_basic", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + expected = "Basic " + Base64.strict_encode64("pre-registered-client:pre-registered-secret") + assert_requested(:post, "#{@auth_base}/token") do |req| + req.headers["Authorization"] == expected && + !URI.decode_www_form(req.body).to_h.key?("client_secret") + end + end + + def test_run_form_urlencodes_basic_auth_credentials_with_special_characters + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + # Per RFC 6749 Section 2.3.1, credentials must be `application/x-www-form-urlencoded` + # before being joined with ":" and base64-encoded. A `:` in the secret would + # otherwise be ambiguous with the username/password separator. + provider.save_client_information( + "client_id" => "client:with colon", + "client_secret" => "secret:with:colons and spaces", + "token_endpoint_auth_method" => "client_secret_basic", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + encoded_id = URI.encode_www_form_component("client:with colon") + encoded_secret = URI.encode_www_form_component("secret:with:colons and spaces") + expected = "Basic " + Base64.strict_encode64("#{encoded_id}:#{encoded_secret}") + assert_requested(:post, "#{@auth_base}/token") do |req| + req.headers["Authorization"] == expected + end + end + + def test_run_rejects_non_https_resource_metadata_url + # `WWW-Authenticate` may carry a `resource_metadata=` URL we have never + # seen before; if it isn't HTTPS-or-loopback, an attacker could lure us + # into fetching a doctored PRM document over plain HTTP. + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: "http://attacker.example.com/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message) + end + + def test_run_rejects_when_as_metadata_issuer_does_not_match + # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal + # the discovery URL. A metadata document advertising a different issuer + # than the one PRM pointed at is treated as a confused-deputy attempt. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://other-auth.example.com", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash + # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent + # after normalization". `https://auth.example.com` and + # `https://auth.example.com/` are different strings and MUST NOT match. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "#{@auth_base}/", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_issuer_differs_by_case + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base.upcase, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_lacks_issuer + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_non_https_authorization_server + # Communication Security: an `http://` authorization server URL (other + # than loopback) MUST be refused. Returning a non-HTTPS auth server + # from a hijacked PRM would let the attacker steer the client at + # an endpoint they control to harvest tokens. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: ["http://auth.example.com"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/HTTPS|Communication Security/i, error.message) + end + + def test_run_rejects_non_https_token_endpoint + # Even when PRM and AS metadata discovery happen over HTTPS, the AS + # metadata document itself can advertise an `http://` token endpoint. + # That endpoint is where the bearer token lands, so it MUST be rejected. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "http://insecure.example.com/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/HTTPS|Communication Security|token_endpoint/i, error.message) + end + + def test_refresh_swaps_refresh_token_for_new_access_token + # Refresh should NOT hit /register (no DCR) and SHOULD post + # grant_type=refresh_token with the saved refresh_token. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + # New response did not include a refresh_token, so the old one is preserved (RFC 6749 Section 6). + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_raises_when_no_refresh_token + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "only-at") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_when_token_endpoint_rejects_refresh + stub_request(:post, "#{@auth_base}/token").to_return(status: 401, body: "") + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_invalid_grant_error_when_token_endpoint_says_invalid_grant + stub_request(:post, "#{@auth_base}/token").to_return( + status: 400, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(error: "invalid_grant", error_description: "refresh token expired"), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") + + assert_raises(Flow::InvalidGrantError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_generic_authorization_error_on_5xx + stub_request(:post, "#{@auth_base}/token").to_return(status: 503, body: "") + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "live-rt") + + err = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + refute_kind_of( + Flow::InvalidGrantError, + err, + "5xx must not be classified as invalid_grant; transient failures must preserve the refresh token", + ) + end + + def test_resolve_scope_prefers_prm_scopes_supported_over_provider_scope + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write"], + ), + ) + + captured = nil + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + scope: "provider-supplied-scope", + redirect_handler: ->(url) { captured = url }, + callback_handler: -> { + [ + "code-1", + URI.decode_www_form(captured.query).to_h["state"], + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: @prm_url, + scope: nil, + ) + + authorized_params = URI.decode_www_form(captured.query).to_h + assert_equal( + "mcp:read mcp:write", + authorized_params["scope"], + "PRM scopes_supported must win over provider.scope when challenge scope is absent", + ) + end + + def test_resolve_scope_falls_back_to_provider_scope_when_prm_omits_scopes_supported + captured = nil + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + scope: "provider-fallback", + redirect_handler: ->(url) { captured = url }, + callback_handler: -> { + [ + "code-1", + URI.decode_www_form(captured.query).to_h["state"], + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: @prm_url, + scope: nil, + ) + + authorized_params = URI.decode_www_form(captured.query).to_h + assert_equal( + "provider-fallback", + authorized_params["scope"], + "provider.scope is the last-resort fallback when neither challenge nor PRM supplies one", + ) + end + + def test_token_endpoint_defaults_to_client_secret_basic_for_confidential_clients + captured_authorization = nil + captured_form = nil + stub_request(:post, "#{@auth_base}/token").to_return do |req| + captured_authorization = req.headers["Authorization"] + captured_form = URI.decode_www_form(req.body).to_h + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "tok", token_type: "Bearer"), + } + end + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + # Confidential pre-registered client without `token_endpoint_auth_method`. + provider.save_client_information( + "client_id" => "preregistered", + "client_secret" => "s3cret", + ) + provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + expected = Base64.strict_encode64("preregistered:s3cret") + assert_equal( + "Basic #{expected}", + captured_authorization, + "default to client_secret_basic per RFC 6749 §2.3.1 and TS/Python SDK convention", + ) + refute( + captured_form.key?("client_secret"), + "client_secret must not be duplicated in the form when sent via Basic auth", + ) + end + + def test_run_blocks_percent_encoded_dot_segment_bypass_in_server_url + # Same audience-binding bypass as the literal `..` case, but using + # the percent-encoded form `%2e%2e`. RFC 3986 Section 6.2.2.2 normalization must + # decode `%2e`/`%2E` before resolving dot-segments. + stub_request(:get, "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/api", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/api/%2e%2e/evil", + resource_metadata_url: "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_blocks_dot_segment_bypass_in_server_url + # An attacker-supplied server URL like "/api/../evil" must not be + # treated as covered by a PRM whose resource is the more privileged + # "/api". Canonicalization resolves the dot-segments so the bypass + # is detected. + stub_request(:get, "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/api", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/api/../evil", + resource_metadata_url: "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_strips_userinfo_from_resource_parameter_when_server_url_carries_credentials + # `Discovery.canonicalize_url` drops `user:pass@`, so the RFC 8707 + # `resource` parameter sent on both the authorization request and + # the token exchange must contain only the origin + path: shipping + # the raw URL would hand the user's credentials to the authorization + # server. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + captured_url = nil + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_url = url }, + callback_handler: -> { + [ + "test-auth-code", + URI.decode_www_form(captured_url.query).to_h.fetch("state"), + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: "https://hunter2:t0psecret@srv.example.com/mcp", + resource_metadata_url: @prm_url, + ) + + query = URI.decode_www_form(captured_url.query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + refute_includes(query["resource"], "hunter2") + refute_includes(query["resource"], "t0psecret") + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_resource_mismatch_error_does_not_leak_userinfo + # When PRM advertises a `resource` that does not cover the MCP server + # URL, the mismatch error message MUST cite the canonicalized URLs so + # that `user:pass@` cannot reach logs, stack traces, or exception + # reporters. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://hunter2:t0psecret@evil.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://hunter2:t0psecret@srv.example.com/mcp", + resource_metadata_url: @prm_url, + ) + end + + refute_includes(error.message, "hunter2") + refute_includes(error.message, "t0psecret") + end + + def test_run_blocks_cross_tenant_query_bypass + # A hijacked PRM that advertises a different tenant's `resource` + # MUST cause an audience-binding failure rather than allowing + # the client to mint a token bound to the attacker's tenant for + # the victim's MCP server. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp?tenant=evil", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/mcp?tenant=victim", + resource_metadata_url: @prm_url, + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_raises_on_resource_mismatch + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://evil.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/resource/i, error.message) + end + + def test_run_sends_server_url_as_resource_when_prm_omits_it + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:url] = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + query = URI.decode_www_form(state_holder[:url].query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp" + end + end + end + end + end +end diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb new file mode 100644 index 0000000..9aaf572 --- /dev/null +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -0,0 +1,1086 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "webmock/minitest" +require "faraday" +require "mcp/client/http" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class HTTPOAuthTest < Minitest::Test + def setup + WebMock.enable! + @mcp_url = "https://srv.example.com/mcp" + @prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp" + @auth_base = "https://auth.example.com" + end + + def teardown + WebMock.reset! + end + + def build_provider + Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + end + + def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token + # First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"].nil? } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer test-token-after-flow" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("test-token-after-flow", provider.access_token) + end + + def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set + # A bearer token sent to `http://attacker.example.com/mcp` would leak + # over the wire. When OAuth is on, the transport URL must clear + # the same Communication Security bar as every other OAuth-related URL. + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + MCP::Client::HTTP.new(url: "http://attacker.example.com/mcp", oauth: provider) + end + end + + def test_initialize_allows_loopback_http_mcp_url_when_oauth_is_set + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + # Loopback HTTP is permitted for local development / conformance. + MCP::Client::HTTP.new(url: "http://localhost:9292/mcp", oauth: provider) + MCP::Client::HTTP.new(url: "http://127.0.0.1:9292/mcp", oauth: provider) + end + + def test_initialize_allows_non_secure_mcp_url_when_oauth_is_nil + # Without OAuth, no bearer tokens are sent, so the SDK leaves + # the scheme decision to the caller. + MCP::Client::HTTP.new(url: "http://internal.example.com/mcp") + end + + def test_refresh_path_preserves_query_string_in_resource_claim + # The refresh flow MUST send the same canonical `resource` claim as + # the original authorization request -- otherwise the AS rejects + # the refresh as a different audience. Verify by giving the transport + # a query-bearing MCP URL and a saved refresh token, then asserting + # the refresh POST body carries the full URL. + tenant_mcp_url = "https://srv.example.com/mcp?tenant=1" + + stub_request(:post, tenant_mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer refreshed-tenant-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, tenant_mcp_url) + .with(headers: { "Authorization" => "Bearer refreshed-tenant-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-tenant-token", token_type: "Bearer", expires_in: 3600), + ) + + provider = build_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "valid-rt") + + transport = HTTP.new(url: tenant_mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + + # The refresh POST body's `resource` MUST keep the query string so it + # matches the audience the original token was minted for. + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "refresh_token" && + form["resource"] == "https://srv.example.com/mcp?tenant=1" + end + end + + def test_send_request_refreshes_when_refresh_token_is_available + # On 401 with a saved refresh_token, the transport should swap it for + # a fresh access token rather than re-running the full interactive + # Authorization Code flow. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer refreshed-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer refreshed-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # DCR and /authorize must not be invoked when refresh succeeds. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:get, "#{@auth_base}/authorize").to_raise(StandardError.new("authorize should not be called.")) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600), + ) + + provider = build_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("refreshed-token", provider.access_token) + end + + def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure + # A 5xx (or any non-`invalid_grant`) from the token endpoint indicates + # a transient AS outage, NOT that the refresh token is dead. + # The transport must fall through to the full Authorization Code flow + # (we cannot serve the MCP request without a fresh access token), but + # the stored refresh_token must remain so subsequent attempts on + # the next outage-free request can succeed without interactive reauth. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer brand-new-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer brand-new-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # Refresh request: transient 503. Auth code exchange: succeeds with + # a NEW refresh token (so we can prove the live RT survived rather than + # being silently overwritten by the success path). + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return(status: 503, body: "") + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "authorization_code")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + # The transient 503 must not wipe the refresh token; the auth-code + # success path overwrites tokens, but we asserted the prior refresh + # token survived the refresh-attempt failure by NOT seeing the full + # flow trigger `clear_tokens!`. To prove this, inspect that + # the final stored refresh_token is whatever the auth-code response + # provided (omitted here), not nil from a forced clear. + stored = provider.tokens || {} + assert_equal("brand-new-token", stored["access_token"] || stored[:access_token]) + end + + def test_send_request_falls_back_to_full_flow_when_refresh_fails + # If the refresh request itself is rejected (refresh token revoked), + # the transport should clear stale tokens and fall through to + # the full Authorization Code flow. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer brand-new-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer brand-new-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # First /token call (refresh) fails; second call (authorization_code) succeeds. + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return(status: 401, body: "") + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "authorization_code")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "revoked-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("brand-new-token", provider.access_token) + end + + def test_oauth_flow_preserves_query_string_in_resource_claim + # Query parameters on the MCP URL (e.g. `?tenant=...`) must be carried + # into the RFC 8707 `resource` claim sent on the authorization and + # token requests, matching how the TS and Python SDKs build + # the canonical resource URL. The URL guard's snapshot is allowed to drop + # the query because Faraday does the same at request time, but + # the OAuth-side snapshot must retain it. + tenant_mcp_url = "https://srv.example.com/mcp?tenant=1" + + stub_request(:post, tenant_mcp_url) + .with { |req| req.headers["Authorization"].nil? } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, tenant_mcp_url) + .with(headers: { "Authorization" => "Bearer tenant-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + # PRM is allowed to omit `resource`; the client falls back to its + # canonical MCP URL (query and all). + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "tenant-token", token_type: "Bearer", expires_in: 3600), + ) + + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_authorization_url = url }, + callback_handler: -> { + query = URI.decode_www_form(captured_authorization_url.query).to_h + ["test-auth-code", query.fetch("state")] + }, + ) + + transport = HTTP.new(url: tenant_mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + + # The authorization URL's `resource` parameter MUST keep the query. + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://srv.example.com/mcp?tenant=1", query["resource"]) + + # The token endpoint POST body's `resource` MUST also keep the query. + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp?tenant=1" + end + end + + def test_oauth_flow_uses_validated_url_after_url_mutation + # Even if an attacker mutates `@url` after construction (e.g. + # `instance_variable_set(:@url, "https://attacker.example.com/mcp")`), + # the OAuth discovery / authorization code flow MUST continue to use + # the URL snapshotted at initialize time. Otherwise PRM, AS metadata, + # and the `resource` claim sent to the authorization server would + # all point at the attacker-controlled host. + attacker_prm = "https://attacker.example.com/.well-known/oauth-protected-resource/mcp" + attacker_root_prm = "https://attacker.example.com/.well-known/oauth-protected-resource" + stub_request(:get, attacker_prm).to_raise(StandardError.new("PRM must not target attacker host.")) + stub_request(:get, attacker_root_prm).to_raise(StandardError.new("PRM must not target attacker host.")) + + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer post-mutation-token" } + .to_return( + status: 401, + # No `resource_metadata` to force PRM derivation from + # the server_url -- this is exactly the path where a mutated + # `@url` could redirect discovery without the snapshot. + headers: { "WWW-Authenticate" => 'Bearer error="invalid_token"' }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "post-mutation-token", token_type: "Bearer", expires_in: 3600), + ) + + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_authorization_url = url }, + callback_handler: -> { + query = URI.decode_www_form(captured_authorization_url.query).to_h + ["test-auth-code", query.fetch("state")] + }, + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer post-mutation-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + # Force the lazy Faraday connection to be built against the validated + # URL before the mutation. After this, `@client.url_prefix` is + # locked to `srv.example.com` regardless of what `@url` later points + # at, which is what the URL guard middleware reads. + transport.send(:client) + transport.instance_variable_set(:@url, "https://attacker.example.com/mcp") + + # The first 401 must drive `run_oauth_flow!` against the validated + # URL, not the mutated `@url`. The OAuth flow succeeds, the retry + # carries the new bearer token, and Faraday (still pointed at + # the safe URL via its frozen `url_prefix`) returns the stubbed 200. + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + # Discovery must have aimed at the validated host. The attacker-host + # PRM stubs would have raised otherwise. + assert_requested(:get, @prm_url) + refute_requested(:get, attacker_prm) + refute_requested(:get, attacker_root_prm) + # The `resource` claim sent on the authorization URL is built from + # the validated URL, not the attacker-host mutation. + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + refute_includes(query["resource"], "attacker.example.com") + end + + def test_send_request_rejects_url_mutation_after_initialize + # The constructor secure-URL check alone is not enough: `@url` is + # a plain instance variable, so a caller can `instance_variable_set` it + # to a non-secure URL after the transport is built. The send-time + # re-check must catch this and refuse to send the bearer token. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) + transport.instance_variable_set(:@url, "http://attacker.example.com/mcp") + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_faraday_customizer_url_swap + # An `&block` Faraday customizer can rewrite `url_prefix` to point at + # a plaintext attacker host; the constructor URL check would miss this. + # The send-time effective-URL check must reject it. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.url_prefix = "http://attacker.example.com/mcp" + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_https_to_different_host_via_customizer + # An `https://` URL alone is not enough - the URL must also match + # the one validated at initialize time. A customizer that swaps to + # a *different* https host (e.g. `https://attacker.example.com/mcp`) + # would otherwise pass a naive "still https?" check and leak + # the bearer token to the attacker. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.url_prefix = "https://attacker.example.com/mcp" + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_middleware_env_url_query_rewrite + # A Faraday middleware that swaps only the query (`tenant=victim` -> + # `tenant=evil`) must be caught by the URL guard. Otherwise + # an attacker who can inject middleware can ship the bearer token to + # a different tenant of the same origin/path while the OAuth-side + # audience binding stays correct. + rewrite_query_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url.query = "tenant=evil" + @app.call(env) + end + end + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + transport = MCP::Client::HTTP.new( + url: "https://srv.example.com/mcp?tenant=victim", + oauth: provider, + ) do |faraday| + faraday.use(rewrite_query_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_middleware_env_url_rewrite + # A Faraday middleware can mutate `env.url` at request time, which + # `Faraday::Connection#url_prefix` cannot detect. The identity guard + # must run as a middleware *after* the customizer so it sees + # the rewritten env.url before it reaches the adapter. + rewrite_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url = URI("https://attacker.example.com/mcp") + @app.call(env) + end + end + + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.use(rewrite_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_accepts_validated_url_with_userinfo + # Faraday hoists `user:pass@` out of the URL into an `Authorization: + # Basic` header, so `env.url` at request time does not carry + # the userinfo. The identity guard must drop userinfo on both sides to + # avoid a false-positive `InsecureURLError` on this valid URL. + stub_request(:post, "https://srv.example.com/mcp") + .with(headers: { "Authorization" => /Basic|Bearer/ }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://user:pass@srv.example.com/mcp", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_insecure_url_error_does_not_leak_userinfo + # The error message MUST surface only the canonicalized (origin + path) + # form so that `user:pass@` cannot leak into logs, stack traces, or + # exception reporters. + provider = build_provider + error = assert_raises(MCP::Client::HTTP::InsecureURLError) do + MCP::Client::HTTP.new(url: "http://hunter2:t0psecret@attacker.example.com/mcp", oauth: provider) + end + + refute_includes(error.message, "hunter2") + refute_includes(error.message, "t0psecret") + end + + def test_send_request_accepts_validated_url_with_query_string + # A `?tenant=1` style query on the user-supplied URL must not trip + # the identity guard. `Faraday::Connection#url_prefix.to_s` drops + # the query, so naive string-compare would raise a false-positive + # `InsecureURLError` on the very first request. + stub_request(:post, "https://srv.example.com/mcp?tenant=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant=1", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_unsorted_query_parameters + # Faraday sorts query parameters by name before sending the request, + # so the snapshot and the effective URL must compare as equal even + # when the user-supplied URL listed `b` first and `a` second. + stub_request(:post, "https://srv.example.com/mcp?b=2&a=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?b=2&a=1", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_lowercase_percent_encoded_hex + # Faraday uppercases percent-encoded hex digits (`%2f` -> `%2F`), + # so the snapshot and the effective URL must compare as equal even + # when the user-supplied URL used lowercase hex. + stub_request(:post, "https://srv.example.com/mcp?x=%2f").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?x=%2f", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_valueless_query_key + # `?tenant` (no `=`) and `?tenant=` (empty value) are distinct in + # Faraday's encoding. The snapshot must preserve that distinction + # so the URL guard does not false-positive when the user-supplied + # URL omits the `=`. + stub_request(:post, "https://srv.example.com/mcp?tenant").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_array_form_query_parameters + # Faraday preserves array-form (`?roles[]=a&roles[]=b`) duplicates + # rather than collapsing them. The snapshot must do the same so + # the URL guard does not raise a false positive. + stub_request(:post, "https://srv.example.com/mcp?roles[]=a&roles[]=b").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?roles[]=a&roles[]=b", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_rejects_array_form_query_entry_drop_via_middleware + # An attacker middleware that strips one value from a multi-value + # array-form query (`?tenant[]=victim&tenant[]=victim2` -> + # `?tenant[]=victim`) must be caught by the URL guard. If + # the snapshot collapsed duplicates, both forms would compare as equal + # and the bearer token would leak to a partially-rewritten audience. + drop_first_value_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url.query = "tenant%5B%5D=victim" + @app.call(env) + end + end + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + transport = MCP::Client::HTTP.new( + url: "https://srv.example.com/mcp?tenant[]=victim&tenant[]=victim2", + oauth: provider, + ) do |faraday| + faraday.use(drop_first_value_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_accepts_url_with_blank_query_segments + # Faraday's query encoder drops empty-name pairs and blank `&&` + # separators (`?&&a=1&&` -> `?a=1`). The snapshot must do the same + # so the URL guard does not raise a false positive. + stub_request(:post, "https://srv.example.com/mcp?a=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?&&a=1&&", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_repeated_query_parameters + # Faraday's query encoder collapses repeated same-name parameters + # to the last value (`?a=1&a=2` -> `?a=2`). The snapshot must apply + # the same collapse so the URL guard does not raise a false + # positive. + stub_request(:post, "https://srv.example.com/mcp?a=2").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?a=1&a=2", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_empty_query_marker + # Faraday drops a bare trailing `?` with no parameters, so + # the snapshot and the effective URL must compare as equal even when + # the user-supplied URL kept the `?` marker. + stub_request(:post, "https://srv.example.com/mcp").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_rejects_https_to_different_host_via_url_mutation + # Same identity-binding requirement when the swap goes through + # `instance_variable_set(:@url, ...)`: the new URL is HTTPS, but it + # is not the URL that was validated at initialize time. + stub_request(:post, "https://attacker.example.com/mcp").to_return(status: 200, body: "") + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) + transport.instance_variable_set(:@url, "https://attacker.example.com/mcp") + transport.instance_variable_set(:@client, nil) # force Faraday rebuild against mutated @url + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_raises_unauthorized_when_no_oauth_provider + stub_request(:post, @mcp_url).to_return(status: 401, body: "") + transport = HTTP.new(url: @mcp_url) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + assert_equal(:unauthorized, error.error_type) + end + + def test_send_request_does_not_loop_when_oauth_flow_fails + stub_request(:post, @mcp_url).to_return(status: 401, body: "") + stub_request(:get, @prm_url).to_return(status: 404, body: "") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return( + status: 404, + body: "", + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(Flow::AuthorizationError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_does_not_re_run_oauth_flow_on_repeated_401 + # Server keeps returning 401 even after a successful token exchange. + # The retry guard MUST limit OAuth flow execution to one attempt per + # `send_request` so we surface the failure instead of looping. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "still-bad-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + # Token endpoint should be hit at most once across the failing call, + # proving the retry guard prevents an infinite re-authorization loop. + assert_requested(:post, "#{@auth_base}/token", times: 1) + end + end + end + end +end diff --git a/test/mcp/client/oauth/pkce_test.rb b/test/mcp/client/oauth/pkce_test.rb new file mode 100644 index 0000000..49a628b --- /dev/null +++ b/test/mcp/client/oauth/pkce_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "digest" +require "mcp/client/oauth/pkce" + +module MCP + class Client + module OAuth + class PKCETest < Minitest::Test + def test_generate_returns_verifier_and_s256_challenge + result = PKCE.generate + + assert_kind_of(String, result[:code_verifier]) + assert_operator(result[:code_verifier].length, :>=, 43) + assert_operator(result[:code_verifier].length, :<=, 128) + assert_equal("S256", result[:code_challenge_method]) + + expected = Base64.urlsafe_encode64(Digest::SHA256.digest(result[:code_verifier]), padding: false) + assert_equal(expected, result[:code_challenge]) + end + + def test_generate_produces_distinct_values + a = PKCE.generate + b = PKCE.generate + + refute_equal(a[:code_verifier], b[:code_verifier]) + refute_equal(a[:code_challenge], b[:code_challenge]) + end + end + end + end +end diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb new file mode 100644 index 0000000..ed4db35 --- /dev/null +++ b/test/mcp/client/oauth/provider_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class ProviderTest < Minitest::Test + # Builds a complete Provider argument hash whose `client_metadata` + # registers the same `redirect_uri` that is being asserted on. + # The registered-URI check in `Provider#initialize` requires the two to + # match, so accept-path tests need them paired. + def args_for(redirect_uri) + { + client_metadata: { + redirect_uris: [redirect_uri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: redirect_uri, + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + } + end + + def test_initialize_accepts_https_redirect_uri + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_equal("https://app.example.com/callback", provider.redirect_uri) + end + + def test_initialize_accepts_loopback_http_redirect_uri + ["http://localhost/callback", "http://127.0.0.1:3000/cb", "http://[::1]/cb"].each do |uri| + provider = Provider.new(**args_for(uri)) + assert_equal(uri, provider.redirect_uri) + end + end + + def test_initialize_rejects_non_loopback_http_redirect_uri + # Communication Security: a non-loopback `http://` redirect URI would + # let an attacker steal the authorization code from a network sniffer, + # so the provider must refuse to construct. + assert_raises(Provider::InsecureRedirectURIError) do + Provider.new(**args_for("http://app.example.com/callback")) + end + end + + def test_initialize_rejects_non_http_scheme_redirect_uri + assert_raises(Provider::InsecureRedirectURIError) do + Provider.new(**args_for("ftp://app.example.com/callback")) + end + end + + def test_initialize_rejects_hostname_tricks_that_resemble_loopback + ["http://127.attacker.com/cb", "http://127.0.0.1.evil.com/cb", "http://foo.localhost/cb"].each do |uri| + assert_raises(Provider::InsecureRedirectURIError, "should reject #{uri}") do + Provider.new(**args_for(uri)) + end + end + end + + def test_initialize_rejects_redirect_uri_not_listed_in_client_metadata + # Per the OAuth Dynamic Client Registration response, the AS will + # bind the client_id to a specific set of redirect_uris and reject any + # authorization request that uses something else. Detect that mismatch + # at Provider construction so the failure surfaces immediately. + args = args_for("http://localhost:0/callback") + args[:redirect_uri] = "http://localhost:0/different" + + assert_raises(Provider::UnregisteredRedirectURIError) do + Provider.new(**args) + end + end + + def test_initialize_accepts_redirect_uri_with_string_keys_in_client_metadata + # `client_metadata` is hand-built by callers, so accept either symbol + # or string keys for `redirect_uris`. + args = args_for("http://localhost:0/callback") + args[:client_metadata] = { "redirect_uris" => ["http://localhost:0/callback"] } + + provider = Provider.new(**args) + assert_equal("http://localhost:0/callback", provider.redirect_uri) + end + end + end + end +end From f62d8136256dabb44310dd948b4b1bbbc6951560 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 01:24:15 +0900 Subject: [PATCH 076/247] Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport` ## Motivation and Context MCP 2025-11-25 does not support JSON-RPC batch (batch support was removed in 2025-06-18), so request bodies are expected to be a single JSON-RPC message object. The previous code path: ```ruby body = parse_request_body(body_string) return body unless body.is_a?(Hash) ``` returned the parsed JSON value as if it were a Rack response when the body was a JSON array (former batch shape), a string, a number, or any other non-Hash JSON value. This produced a malformed Rack response instead of a proper HTTP 400. This was a pre-existing bug exposed during follow-up review of #347 (`MCP-Protocol-Version` header validation), which surfaced it but did not fix the broken response shape for non-Hash bodies sent with a valid header. ### Behavior - `parse_request_body` now raises a private `InvalidJsonError` on parse failure instead of returning a Rack response tuple. The overloaded return type and the `parse_error_tuple?` discriminator are gone. - `handle_post` rescues `InvalidJsonError` and returns the same plain JSON 400 response as before (`{"error":"Invalid JSON"}`), so parse error handling is observably unchanged. - Non-Hash parsed bodies (arrays, strings, numbers, booleans, null) now return HTTP 400 with a plain JSON body explaining that the body must be a single JSON-RPC message object, instead of producing a malformed Rack response. The error response shape stays in the existing plain JSON style for consistency with `validate_content_type`, `not_acceptable_response`, and the other transport-level error helpers. Unifying all of these to a JSON-RPC error envelope (matching the Python and TypeScript SDKs) is deferred to a separate follow-up. ### SDK Comparison - Python SDK (`src/mcp/server/streamable_http.py`): Already rejects non-Hash bodies (Array, primitive) with HTTP 400. Uses a JSON-RPC error envelope with `INVALID_PARAMS`. - TypeScript SDK (`packages/server/src/server/streamableHttp.ts`): As of `main`, still processes JSON arrays as JSON-RPC batches and has not yet caught up with the 2025-06-18 batch removal. Primitives fail schema validation and return 400 with a JSON-RPC error envelope. This change brings the Ruby SDK in line with the current MCP spec (no batch). The Ruby SDK is now closer to the spec than the TypeScript SDK's `main` branch on this specific point. ## How Has This Been Tested? Existing regression test `test "handles POST request with invalid JSON"` continues to pass unchanged (plain JSON response preserved). Added new regression tests: - POST with a JSON array body returns 400 with a clear error message - POST with a non-object JSON body (e.g. `"foo"`) returns 400 `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes None for compliant clients sending single JSON-RPC message objects. Clients that were sending JSON arrays (batch requests, no longer supported as of MCP 2025-11-25) or other non-object bodies will now receive a proper HTTP 400 with a JSON error body instead of the previous malformed Rack response; no client could have relied on the broken pre-existing behavior. --- .../transports/streamable_http_transport.rb | 24 +++++++-- .../streamable_http_transport_test.rb | 54 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 9258ec1..436758b 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -16,6 +16,8 @@ module MCP class Server module Transports class StreamableHTTPTransport < Transport + class InvalidJsonError < StandardError; end + SSE_HEADERS = { "Content-Type" => "text/event-stream", "Cache-Control" => "no-cache", @@ -339,8 +341,11 @@ module MCP body_string = request.body.read session_id = extract_session_id(request) - body = parse_request_body(body_string) - return body if parse_error_tuple?(body) + begin + body = parse_request_body(body_string) + rescue InvalidJsonError + return invalid_json_response + end unless initialize_request?(body) return missing_session_id_response if !@stateless && !session_id @@ -349,7 +354,8 @@ module MCP return protocol_version_error if protocol_version_error end - return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25. + # MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object. + return non_hash_body_response unless body.is_a?(Hash) if initialize_request?(body) handle_initialization(body_string, body) @@ -507,11 +513,19 @@ module MCP def parse_request_body(body_string) JSON.parse(body_string, symbolize_names: true) rescue JSON::ParserError, TypeError + raise InvalidJsonError + end + + def invalid_json_response [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end - def parse_error_tuple?(body) - body.is_a?(Array) && body.size == 3 && body[0] == 400 + def non_hash_body_response + [ + 400, + { "Content-Type" => "application/json" }, + [{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json], + ] end def initialize_request?(body) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9d74398..f7be9be 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -96,6 +96,60 @@ module MCP assert_equal "Invalid JSON", body["error"] end + test "POST request with JSON array body returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + [{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"], "single JSON-RPC message object" + end + + test "POST request with non-object JSON body returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + "\"foo\"", + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"], "single JSON-RPC message object" + end + test "handles POST request with initialize method" do request = create_rack_request( "POST", From 7db22f4d5bfeeb5d6f5e0e2d1bc77470f7c20eda Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 18 May 2026 13:00:23 +0900 Subject: [PATCH 077/247] Reject duplicate `initialize` requests (#350) ## Motivation and Context The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized. On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities` (including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh `Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session. MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase MUST be the first interaction between client and server; re-initialization on an established session is not part of the defined lifecycle. TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600` ("Invalid Request: Server already initialized"), and Python SDK does not mint a new session on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK. - `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and set by `mark_initialized!` after a successful `initialize` response. - `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when the session is already initialized, which the existing error mapping converts to JSON-RPC `-32600 Invalid Request`. - `StreamableHTTPTransport#handle_post` short-circuits at the transport layer: duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC `-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry instead of misreporting it as a duplicate). - `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id` header when the first `initialize` fails before `mark_initialized!` is reached, so retries do not collide with an orphaned ID. - Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`) are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to an unparseable Rack response. ## How Has This Been Tested? - Server tests: a second `initialize` on the same `ServerSession` returns `code: -32600` and the original `clientInfo` is preserved. - Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id` returns HTTP 400 + `-32600` and the original session remains usable for subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired session is evicted on duplicate `initialize` and returns 404; a failed `initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`. - Stdio tests: a second `initialize` on the same stdio session returns `code: -32600` and the original `clientInfo` is preserved. ## Breaking Changes Clients that previously sent `initialize` more than once on the same session now receive a JSON-RPC error with `code: -32600` for the second and later requests instead of silently overwriting session state (stdio) or being re-issued a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification (single `initialize` per session) are unaffected. Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response. The previous behavior produced an unparseable response, so this is unlikely to affect any working client. Closes #349. --- lib/mcp/server.rb | 9 ++ .../transports/streamable_http_transport.rb | 79 +++++++++-- lib/mcp/server_session.rb | 13 ++ .../server/transports/stdio_transport_test.rb | 49 +++++++ .../streamable_http_transport_test.rb | 127 +++++++++++++++++- test/mcp/server_test.rb | 25 ++++ 6 files changed, 289 insertions(+), 13 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index de2b4aa..fda614e 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -497,6 +497,13 @@ module MCP end def init(params, session: nil) + # MCP spec: the initialization phase MUST be the first interaction between client and server. + # Reject duplicate `initialize` on an already-initialized session so the negotiated + # client identity and capabilities cannot be silently overwritten. + if session&.initialized? + raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request) + end + if params if session session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) @@ -524,6 +531,8 @@ module MCP response_instructions = nil end + session&.mark_initialized! + { protocolVersion: negotiated_version, capabilities: capabilities, diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 1c246f3..3a642e2 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -347,6 +347,13 @@ module MCP return invalid_json_response end + # Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST. + # Batched/array bodies are not supported; reject with `-32600` instead of falling through to + # a malformed Rack response. + unless body.is_a?(Hash) + return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") + end + # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # This matches the TypeScript and Python SDKs. @@ -357,10 +364,18 @@ module MCP return protocol_version_error if protocol_version_error end - # MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object. - return non_hash_body_response unless body.is_a?(Hash) - if initialize_request?(body) + if !@stateless && session_id + # An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate + # initialization attempt against a live session, or a retry against an unknown/expired + # one. In the live case, reject with `-32600` so the original session is not abandoned. + # In the unknown/expired case, return 404 so the client retries from scratch instead + # of silently inheriting a fresh session under the old ID. + return already_initialized_response(body[:id]) if session_active?(session_id) + + return session_not_found_response + end + handle_initialization(body_string, body) elsif notification?(body) dispatch_notification(body_string, session_id) @@ -523,14 +538,6 @@ module MCP [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end - def non_hash_body_response - [ - 400, - { "Content-Type" => "application/json" }, - [{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json], - ] - end - def initialize_request?(body) body.is_a?(Hash) && body[:method] == Methods::INITIALIZE end @@ -617,6 +624,15 @@ module MCP @server.handle_json(body_string) end + # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope), + # `mark_initialized!` was never called. Discard the orphaned session and omit + # the `Mcp-Session-Id` header so the client retries from a clean state instead of + # reusing a never-initialized ID that would later look like a duplicate `initialize`. + if server_session && !server_session.initialized? + cleanup_session(session_id) + session_id = nil + end + headers = { "Content-Type" => "application/json", } @@ -751,6 +767,31 @@ module MCP @mutex.synchronize { @sessions.key?(session_id) } end + # Returns true iff a session exists and is not past its idle timeout. Expired sessions + # are evicted as a side effect so a live request never observes a zombie session that + # the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are + # rejecting a request must not extend the session's lifetime. + def session_active?(session_id) + removed = nil + active = @mutex.synchronize do + next false unless (session = @sessions[session_id]) + + if session_expired?(session) + removed = cleanup_session_unsafe(session_id) + next false + end + + true + end + + if removed + close_stream_safely(removed[:get_sse_stream]) + close_post_request_streams(removed) + end + + active + end + def method_not_allowed_response [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]] end @@ -763,6 +804,22 @@ module MCP [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] end + def already_initialized_response(request_id) + invalid_request_response("Invalid Request: Server already initialized", request_id: request_id) + end + + def invalid_request_response(message, request_id: nil) + body = { + jsonrpc: "2.0", + id: request_id, + error: { + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + }, + } + [400, { "Content-Type" => "application/json" }, [body.to_json]] + end + def session_already_connected_response [ 409, diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 7e99f28..9ebea12 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -18,6 +18,19 @@ module MCP @logging_message_notification = nil @in_flight = {} @in_flight_mutex = Mutex.new + @initialized = false + end + + # Whether `initialize` has already completed for this session. + def initialized? + @initialized + end + + # Called by `Server#init` after a successful `initialize` response, so subsequent + # `initialize` requests on the same session can be rejected per MCP spec + # (the initialization phase MUST be the first interaction). + def mark_initialized! + @initialized = true end # Registers a `Cancellation` token for an in-flight request. diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index e9b1011..4671960 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -133,6 +133,55 @@ module MCP end end + test "rejects duplicate initialize on the same stdio session with -32600" do + first = { + jsonrpc: "2.0", + method: "initialize", + id: "first", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "original", version: "1.0" }, + }, + } + second = { + jsonrpc: "2.0", + method: "initialize", + id: "second", + params: { + protocolVersion: "2024-11-05", + clientInfo: { name: "intruder", version: "9.9" }, + }, + } + input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + lines = output.string.lines + assert_equal(2, lines.length) + first_response = JSON.parse(lines[0], symbolize_names: true) + second_response = JSON.parse(lines[1], symbolize_names: true) + + assert_equal("first", first_response[:id]) + refute_nil(first_response[:result]) + + assert_equal("second", second_response[:id]) + assert_equal(-32600, second_response[:error][:code]) + assert_equal("Invalid Request", second_response[:error][:message]) + + session = @transport.instance_variable_get(:@session) + assert_equal({ name: "original", version: "1.0" }, session.client) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + test "handles invalid JSON requests" do invalid_json = "invalid json" output = StringIO.new diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 307b712..facb547 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -120,7 +120,8 @@ module MCP assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_includes body["error"], "single JSON-RPC message object" + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) end test "POST request with non-object JSON body returns 400" do @@ -147,7 +148,8 @@ module MCP assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_includes body["error"], "single JSON-RPC message object" + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) end test "handles POST request with initialize method" do @@ -169,6 +171,127 @@ module MCP assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end + test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "first" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + assert session_id + + duplicate_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "initialize", id: "second" }.to_json, + ) + duplicate_response = @transport.handle_request(duplicate_request) + + assert_equal 400, duplicate_response[0] + body = JSON.parse(duplicate_response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "second", body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/already initialized/i, body["error"]["message"]) + + # Original session should still be usable. + ping_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json, + ) + ping_response = @transport.handle_request(ping_request) + assert_equal 200, ping_response[0] + end + + test "rejects initialize with stale Mcp-Session-Id with 404" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" }, + { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"] + end + + test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.05) + begin + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "first" }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + assert(session_id) + + sleep(0.1) + + duplicate_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "initialize", id: "second" }.to_json, + ) + duplicate_response = transport.handle_request(duplicate_request) + + assert_equal(404, duplicate_response[0]) + body = JSON.parse(duplicate_response[2][0]) + assert_equal("Session not found", body["error"]) + + refute(transport.send(:session_exists?, session_id), "expired session must be evicted") + ensure + transport.close + end + end + + test "evicts session and omits Mcp-Session-Id when initialize fails" do + # An `initialize` whose JSON-RPC envelope is rejected (e.g. wrong `jsonrpc` version) + # never reaches `Server#init`, so `mark_initialized!` is never called. The transport + # must drop the registered-but-uninitialized session to keep retries clean. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "1.0", method: "initialize", id: "broken" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + refute response[1].key?("Mcp-Session-Id"), "no session id should leak from a failed init" + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal({}, @transport.instance_variable_get(:@sessions)) + end + + test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + [{ jsonrpc: "2.0", method: "initialize", id: "batched" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) + end + test "handles GET request with valid session ID" do # First create a session with initialize init_request = create_rack_request( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 9f942a7..890a1ac 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -195,6 +195,31 @@ module MCP assert_instrumentation_data({ method: "ping", client: client_info }) end + test "#handle rejects duplicate initialize on an already-initialized session with -32600" do + session = ServerSession.new(server: @server, transport: mock) + + first_request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { clientInfo: { name: "original", version: "1.0" } }, + } + first_response = @server.handle(first_request, session: session) + refute_nil first_response[:result] + + second_request = { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05" }, + } + second_response = @server.handle(second_request, session: session) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, second_response[:error][:code] + assert_equal "Invalid Request", second_response[:error][:message] + assert_equal({ name: "original", version: "1.0" }, session.client) + end + test "instrumentation data does not include client key when no clientInfo provided" do request = { jsonrpc: "2.0", From 2efd0c32e1f7b662b55620b5ba489489b1716c39 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 20 May 2026 12:31:14 +0900 Subject: [PATCH 078/247] Release 0.17.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 12 ++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2556e..cb3dd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0] - 2026-05-19 + +### Added + +- Add OAuth 2.1 client support for MCP authorization flow (#353) + +### Fixed + +- Validate `MCP-Protocol-Version` header in `StreamableHTTPTransport` (#347) +- Reject duplicate `initialize` requests (#350) +- Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport` (#354) + ## [0.16.0] - 2026-05-14 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 4410816..ff392d2 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.16.0" + VERSION = "0.17.0" end From 999f8b3f39947932049ad70bf4a8bbcce79349c1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 19 Mar 2026 01:42:57 +0900 Subject: [PATCH 079/247] Refactor `handle_delete` to use `extract_session_id` ## Motivation and Context `handle_post` and `handle_get` already use this helper, but `handle_delete` inlined `request.env["HTTP_MCP_SESSION_ID"]`. This aligns all three handlers consistently. ## How Has This Been Tested? It has passed existing tests. ## Breaking Changes None. --- lib/mcp/server/transports/streamable_http_transport.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 3a642e2..1b020df 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -426,7 +426,7 @@ module MCP return success_response end - return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"]) + return missing_session_id_response unless (session_id = extract_session_id(request)) return session_not_found_response unless session_exists?(session_id) protocol_version_error = validate_protocol_version_header(request) From ac7598a13d28dcf204a7d7030fbced46f1737f3e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 20 Apr 2026 03:05:08 +0900 Subject: [PATCH 080/247] Support server-to-client `ping` per MCP specification ## Motivation and Context The MCP specification defines `ping` as a bidirectional utility: either party may issue it. https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping The previous PR added `MCP::Client#ping`. This PR adds the symmetric server-side send capability, `ServerSession#ping`. This aligns the Ruby SDK with the Python SDK (`ServerSession.send_ping`) and the TypeScript SDK (`Server.ping()`), both of which expose a server-initiated ping. In practice this enables a Ruby MCP server to health-check non-Ruby clients (e.g., Claude Desktop, the Python or TypeScript SDK client) during long-running tool operations. Scope is intentionally server-only: this PR does not add client-side inbound request dispatch, which is a larger refactor outside the ping utility. A Ruby `MCP::Client` therefore still cannot receive a server-initiated ping; that gap is tracked as a separate follow-up. ## How Has This Been Tested? New file `test/mcp/server_session_ping_test.rb` covers: - success (empty `result` returned) - `related_request_id` propagated through the transport when a session ID is set - `ValidationError` when the transport returns `nil` - `ValidationError` when the transport returns a non-Hash `result` - transport-level errors propagate unchanged - ping succeeds without any client capability declaration (per spec, ping has no capability gate) `test/mcp/server_context_test.rb` adds two tests for `ServerContext#ping` delegation (happy path and `NoMethodError` when the session lacks `#ping`). The existing server-side `ping` handler tests continue to pass unchanged. ## Breaking Changes None. `MCP::ServerContext#ping` and `MCP::Server::ValidationError` are purely additive. --- README.md | 19 ++++++ lib/mcp/server.rb | 5 ++ lib/mcp/server_context.rb | 22 +++++++ lib/mcp/server_session.rb | 8 +++ test/mcp/server_context_test.rb | 24 +++++++ test/mcp/server_session_ping_test.rb | 93 ++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+) create mode 100644 test/mcp/server_session_ping_test.rb diff --git a/README.md b/README.md index 3f54b31..10dd396 100644 --- a/README.md +++ b/README.md @@ -1253,6 +1253,25 @@ A `ping` request has no parameters, and the receiver MUST respond promptly with Servers respond to incoming `ping` requests automatically - no setup is required. Any `MCP::Server` instance replies with an empty result. +Servers can also send `ping` requests to the client via `ServerSession#ping`. +Inside a tool handler that receives `server_context:`, call `ping` on it: + +```ruby +class HealthCheckTool < MCP::Tool + description "Verifies the client is still responsive" + + def self.call(server_context:) + server_context.ping # => {} on success + + MCP::Tool::Response.new([{ type: "text", text: "client is alive" }]) + end +end +``` + +`#ping` raises `MCP::Server::ValidationError` when the client returns a `result` +that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error) +propagate as exceptions raised by the transport layer. + #### Client-Side `MCP::Client` exposes `ping` to send a ping to the server: diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index fda614e..c1b8be1 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -67,6 +67,11 @@ module MCP end end + # Raised when a client response fails server-side validation, e.g., a success response + # whose `result` field is missing or has the wrong type. This is distinct from a + # client-returned JSON-RPC error. + class ValidationError < StandardError; end + include Instrumentation include Pagination diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 3962869..baed180 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -59,6 +59,28 @@ module MCP end end + # Sends a `ping` request to the originating client to verify it is still responsive. + # Per the MCP spec, the client MUST respond promptly with an empty result. + # + # @return [Hash] An empty hash on success. + # @raise [Server::ValidationError] If the response `result` is not a Hash. + # @raise [NoMethodError] If the session does not support sending pings. + # + # @example + # def self.call(server_context:) + # server_context.ping # => {} + # # ... + # end + # + # @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping + def ping + if @notification_target.respond_to?(:ping) + @notification_target.ping(related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'ping' for #{self}" + end + end + # 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. diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 9ebea12..307217f 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -106,6 +106,14 @@ module MCP send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id) end + # Sends a `ping` request scoped to this session. + def ping(related_request_id: nil) + result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id) + raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash) + + result + end + # Sends a `sampling/createMessage` request scoped to this session. def create_sampling_message(related_request_id: nil, **kwargs) params = @server.build_sampling_params(client_capabilities, **kwargs) diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 623ed85..86fd972 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -67,6 +67,30 @@ module MCP assert_raises(NoMethodError) { server_context.list_roots } end + test "ServerContext#ping delegates to notification_target" do + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil).returns({}) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.ping + + assert_equal({}, result) + end + + test "ServerContext#ping raises NoMethodError when notification_target does not respond" do + notification_target = mock + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + assert_raises(NoMethodError) { server_context.ping } + end + test "ServerContext#create_sampling_message delegates to notification_target over context" do notification_target = mock notification_target.expects(:create_sampling_message).with( diff --git a/test/mcp/server_session_ping_test.rb b/test/mcp/server_session_ping_test.rb new file mode 100644 index 0000000..b467d3e --- /dev/null +++ b/test/mcp/server_session_ping_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerSessionPingTest < ActiveSupport::TestCase + class MockTransport < Transport + attr_reader :requests + attr_accessor :response, :error_to_raise + + def initialize(server) + super + @requests = [] + @response = {} + @error_to_raise = nil + end + + # Matches real transports: returns the `result` body directly (not the full envelope). + def send_request(method, params = nil, session_id: nil, related_request_id: nil) + @requests << { method: method, params: params, session_id: session_id, related_request_id: related_request_id } + raise @error_to_raise if @error_to_raise + + @response + end + + def send_response(response); end + def send_notification(method, params = nil); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + @server = Server.new(name: "test_server", version: "1.0.0") + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + end + + test "#ping sends request through transport and returns the result hash" do + session = ServerSession.new(server: @server, transport: @mock_transport) + + result = session.ping + + assert_equal({}, result) + assert_equal(1, @mock_transport.requests.size) + + request = @mock_transport.requests.first + assert_equal(Methods::PING, request[:method]) + assert_nil(request[:params]) + end + + test "#ping passes related_request_id through when session_id is set" do + session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "session-1") + + session.ping(related_request_id: "req-abc") + + request = @mock_transport.requests.first + assert_equal("session-1", request[:session_id]) + assert_equal("req-abc", request[:related_request_id]) + end + + test "#ping raises ValidationError when result is nil" do + @mock_transport.response = nil + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(Server::ValidationError) { session.ping } + assert_equal("Response validation failed: invalid `result`", error.message) + end + + test "#ping raises ValidationError when result is the wrong type" do + @mock_transport.response = "ok" + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(Server::ValidationError) { session.ping } + assert_equal("Response validation failed: invalid `result`", error.message) + end + + test "#ping propagates transport-level errors" do + @mock_transport.error_to_raise = StandardError.new("read timeout") + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(StandardError) { session.ping } + assert_equal("read timeout", error.message) + end + + test "#ping succeeds without a client capability declaration" do + session = ServerSession.new(server: @server, transport: @mock_transport) + assert_nil(session.client_capabilities) + + assert_equal({}, session.ping) + end + end +end From 9b89620257f1d185287f766466331e1404e09ee8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 23 May 2026 21:24:57 +0900 Subject: [PATCH 081/247] Fix case-sensitive Accept header comparison ## Motivation and Context RFC 9110 defines media type comparison as case-insensitive, but `parse_accept_header` compared types without normalizing case. This caused `Application/JSON` or `TEXT/Event-Stream` to be rejected with 406 Not Acceptable. ## How Has This Been Tested? Added tests for mixed-case Accept headers: - `Accept: Application/JSON, Text/Event-Stream` succeeds for POST - `Accept: TEXT/EVENT-STREAM` succeeds for GET SSE ## Breaking Changes None. This is a bug fix bringing behavior into conformance with RFC 9110. --- .../transports/streamable_http_transport.rb | 2 +- .../streamable_http_transport_test.rb | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 3a642e2..48d4286 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -504,7 +504,7 @@ module MCP def parse_accept_header(header) header.split(",").map do |part| - part.split(";").first.strip + part.split(";").first.strip.downcase 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 facb547..20d094a 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1465,6 +1465,45 @@ module MCP assert_equal 200, response[0] end + test "POST request with mixed-case Accept header succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "Application/JSON, Text/Event-Stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "GET request with upper-case Accept header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { + "HTTP_ACCEPT" => "TEXT/EVENT-STREAM", + "HTTP_MCP_SESSION_ID" => session_id, + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["Content-Type"] + end + test "GET request without Accept header returns 406" do init_request = create_rack_request( "POST", From b6462feef0c1616d39c1f24938ef4183dbe97bfb Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Wed, 6 May 2026 20:22:52 -0400 Subject: [PATCH 082/247] feat: warn on implicit stdio initialization --- lib/mcp/client/stdio.rb | 5 +- test/mcp/client/stdio_test.rb | 130 +++++++++++++++++++++++++++++----- 2 files changed, 116 insertions(+), 19 deletions(-) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 9ddabc8..a273c57 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -134,7 +134,10 @@ module MCP def send_request(request:) start unless @started - connect unless @initialized + unless @initialized + warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1) + connect + end write_message(request) read_response(request) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 4f09abf..6b1b92f 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -9,6 +9,9 @@ require "mcp/client/tool" module MCP class Client class StdioTest < Minitest::Test + IMPLICIT_CONNECT_DEPRECATION_WARNING = + /Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated\. Use `MCP::Client#connect` before sending requests instead\./.freeze + def test_send_request_starts_process_and_returns_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -56,7 +59,10 @@ module MCP stdout_write.flush end - response = transport.send_request(request: request) + response = nil + assert_implicit_connect_deprecation_warning do + response = transport.send_request(request: request) + end assert_equal("test-id", response["id"]) assert_equal(1, response.dig("result", "tools").size) @@ -123,7 +129,9 @@ module MCP stdout_write.flush end - transport.send_request(request: request) + assert_implicit_connect_deprecation_warning do + transport.send_request(request: request) + end assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods) ensure @@ -184,10 +192,13 @@ module MCP stdout_write.flush end - response = transport.send_request(request: request) + response = nil + assert_implicit_connect_deprecation_warning do + response = transport.send_request(request: request) + end assert_equal("test-id", response["id"]) - assert_equal([], response.dig("result", "tools")) + assert_empty(response.dig("result", "tools")) ensure server_thread.join stdin_read.close @@ -217,8 +228,11 @@ module MCP method: "tools/list", } - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Server process has exited", error.message) @@ -268,8 +282,11 @@ module MCP stdout_write.close end - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Server process closed stdout unexpectedly", error.message) @@ -380,7 +397,9 @@ module MCP stdout_write.flush end - transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) + assert_implicit_connect_deprecation_warning do + transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) + end transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) assert_equal( @@ -444,8 +463,11 @@ module MCP stdout_write.flush end - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Failed to parse server response", error.message) @@ -486,8 +508,11 @@ module MCP stdout_write.flush end - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Server initialization failed: Invalid Request", error.message) @@ -563,8 +588,11 @@ module MCP stdin_read.gets end - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Timed out waiting for server response", error.message) @@ -616,7 +644,9 @@ module MCP end # Complete handshake with a successful request - transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) + assert_implicit_connect_deprecation_warning do + transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) + end server_thread.join # Now close stdin to simulate broken pipe @@ -708,8 +738,11 @@ module MCP stdout_write.flush end - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) + error = nil + assert_implicit_connect_deprecation_warning do + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end end assert_equal("Server initialization failed: missing result in response", error.message) @@ -768,6 +801,59 @@ module MCP stdout_write.close end + def test_send_request_does_not_warn_after_explicit_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + + stdin_read.gets + + ping_line = stdin_read.gets + ping_request = JSON.parse(ping_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: ping_request["id"], + result: {}, + )) + stdout_write.flush + end + + assert_silent do + transport.connect + end + + response = nil + assert_silent do + response = transport.send_request(request: { jsonrpc: "2.0", id: "ping-id", method: "ping" }) + end + + assert_equal("ping-id", response["id"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_connect_caches_server_info transport, server_thread, pipes = stub_successful_connect @@ -1141,6 +1227,14 @@ module MCP private + def assert_implicit_connect_deprecation_warning(&block) + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, IMPLICIT_CONNECT_DEPRECATION_WARNING, &block) + ensure + $VERBOSE = original_verbose + end + def stub_successful_connect stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe From a28a34374164e240f59bba7eac378802a2332c94 Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Mon, 25 May 2026 03:01:23 +0900 Subject: [PATCH 083/247] [Doc] Fix typos in MCP::Client method references The methods are defined as singular `call_tool` and `read_resource`, not plural `call_tools` and `read_resources`: https://github.com/modelcontextprotocol/ruby-sdk/blob/dd7daca/lib/mcp/client.rb#L315 https://github.com/modelcontextprotocol/ruby-sdk/blob/dd7daca/lib/mcp/client.rb#L331 --- README.md | 2 +- docs/building-clients.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10dd396..81881a7 100644 --- a/README.md +++ b/README.md @@ -1755,7 +1755,7 @@ This class supports: - Liveness check via the `ping` method (`MCP::Client#ping`) - Tool listing via the `tools/list` method (`MCP::Client#tools`) -- Tool invocation via the `tools/call` method (`MCP::Client#call_tools`) +- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) - Resource listing via the `resources/list` method (`MCP::Client#resources`) - Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) - Resource reading via the `resources/read` method (`MCP::Client#read_resource`) diff --git a/docs/building-clients.md b/docs/building-clients.md index 247ec75..f8f37c7 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -11,7 +11,7 @@ The `MCP::Client` class provides an interface for interacting with MCP servers. **Supported operations:** - Tool listing (`MCP::Client#tools`) and invocation (`MCP::Client#call_tool`) -- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resources`) +- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resource`) - Resource template listing (`MCP::Client#resource_templates`) - Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`) - Completion requests (`MCP::Client#complete`) From 355c90489968e06b3f0d7c87669ba7e97ba9a7f2 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 25 May 2026 02:42:13 +0900 Subject: [PATCH 084/247] Support Client ID Metadata Documents in OAuth client ## Motivation and Context The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs already implement this; the Ruby SDK now reaches parity. The change adds: - `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword. - A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements. Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments (including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings are also rejected, because different encodings of the same query would yield distinct `client_id` strings for the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs, since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback. Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. - `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises `client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values (string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs. - The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information` continues to win over CIMD when both are available. - `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD. When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than sending a `client_id` the authorization server no longer recognises. - The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at the CIMD URL are separate artifacts: DCR metadata MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. - `auth/basic-cimd` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? `Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected; fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty, and malformed inputs rejected. `Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes; root and empty path; fragment; query; userinfo; and dot segments. `Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL; fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR. `Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`; refresh raises when the authorization server no longer advertises CIMD support; stored `client_information` takes precedence over the CIMD URL during refresh. Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`. When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change. The CIMD path is only entered when both the user explicitly configures a URL and the authorization server advertises support via `client_id_metadata_document_supported: true`. --- README.md | 11 + conformance/client.rb | 10 +- conformance/expected_failures.yml | 1 - lib/mcp/client/oauth/discovery.rb | 44 +++ lib/mcp/client/oauth/flow.rb | 42 ++- lib/mcp/client/oauth/provider.rb | 32 +- test/mcp/client/oauth/discovery_test.rb | 62 ++++ test/mcp/client/oauth/flow_test.rb | 434 ++++++++++++++++++++++++ test/mcp/client/oauth/provider_test.rb | 98 ++++++ 9 files changed, 727 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 81881a7..79a1f1a 100644 --- a/README.md +++ b/README.md @@ -1958,6 +1958,17 @@ Optional keyword arguments: - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, which keeps credentials in process memory only. +- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document + (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). + When the authorization server advertises `client_id_metadata_document_supported: true`, + the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, + userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks + them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. + Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document + served at the URL is a separate JSON artifact from the `client_metadata` keyword above: + the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include + `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. To persist credentials across restarts, supply your own storage: diff --git a/conformance/client.rb b/conformance/client.rb index f1efe61..ae8d79b 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -19,6 +19,11 @@ unless scenario && server_url abort("Usage: MCP_CONFORMANCE_SCENARIO= ruby conformance/client.rb ") end +# URL the conformance harness expects to see as the OAuth `client_id` for the `auth/basic-cimd` scenario +# when the AS advertises `client_id_metadata_document_supported`. The harness does not fetch the document, +# only matches the value, so the URL does not need to resolve. +CONFORMANCE_CIMD_URL = "https://conformance-test.local/client-metadata.json" + # The conformance harness optionally injects scenario-specific data via # the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is # defined by the harness, not the MCP spec, and has varied between versions: @@ -50,7 +55,7 @@ end # non-interactively against the conformance test's auth server. The conformance # `/authorize` endpoint redirects synchronously to `redirect_uri` with # `code=test-auth-code`, so we follow it manually instead of opening a browser. -def build_oauth_provider(context) +def build_oauth_provider(context, scenario:) callback_holder = {} redirect_uri = "http://localhost:0/callback" @@ -90,10 +95,11 @@ def build_oauth_provider(context) redirect_handler: redirect_handler, callback_handler: callback_handler, storage: storage, + client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil), ) end -oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : nil +oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index acac417..2f9a327 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -5,7 +5,6 @@ client: # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/basic-cimd - auth/scope-step-up - auth/2025-03-26-oauth-metadata-backcompat - auth/2025-03-26-oauth-endpoint-fallback diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index 87f294d..3360729 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -183,6 +183,50 @@ module MCP false end + # Returns true when `url` satisfies the structural requirements for + # a Client ID Metadata Document URL per the MCP 2025-11-25 + # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`. + # + # Spec-required: + # + # - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply: + # the document URL is sent verbatim to the authorization server as the OAuth `client_id` + # and travels off-loopback) + # - host MUST be present + # - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource, + # not the origin + # - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo + # would leak credentials into every `client_id` log line + # - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with + # the same effective path do not produce different `client_id` strings + # + # SDK policy (stricter than the draft): + # + # - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include, + # but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct + # `client_id` values for the same logical document. + def client_id_metadata_document_url?(url) + return false if url.nil? || url.to_s.empty? + + uri = URI.parse(url.to_s) + return false unless uri.scheme&.downcase == "https" + return false if uri.host.nil? || uri.host.empty? + return false unless uri.fragment.nil? + return false unless uri.query.nil? + return false if uri.respond_to?(:user) && (uri.user || uri.password) + + path = uri.path.to_s + return false if path.empty? || path == "/" + + decoded = path.gsub(/%2[eE]/, ".") + segments = decoded.split("/", -1) + return false if segments.any? { |segment| segment == "." || segment == ".." } + + true + rescue URI::InvalidURIError + false + end + # Like `canonicalize_url` but also strips query string, fragment, and # userinfo. This variant is used for identity comparison against # the request URL Faraday actually sends, which differs from the value diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 3708a48..c6ece4a 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -104,8 +104,16 @@ module MCP refresh_token = read_token("refresh_token") raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token - client_info = @provider.client_information - unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") + stored_client_info = @provider.client_information + have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id") + + # A CIMD-configured provider stores no `client_information` on purpose + # (the CIMD URL is re-resolved against the live AS metadata on every flow). + # Allow refresh to proceed in that case so the `refresh_token` obtained via + # the CIMD flow remains usable. + have_cimd_url = !@provider.client_id_metadata_document_url.nil? + + unless have_stored_client_info || have_cimd_url raise AuthorizationError, "Cannot refresh: no client_information in provider storage." end @@ -126,6 +134,18 @@ module MCP ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) ensure_secure_endpoints!(as_metadata) + client_info = if have_stored_client_info + # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, + # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. + stored_client_info + elsif as_metadata["client_id_metadata_document_supported"] == true + { "client_id" => @provider.client_id_metadata_document_url } + else + raise AuthorizationError, + "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ + "`client_id_metadata_document_supported: true`." + end + new_tokens = exchange_refresh_token( as_metadata: as_metadata, client_info: client_info, @@ -285,6 +305,24 @@ module MCP existing = @provider.client_information return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id") + # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`, + # if the authorization server advertises Client ID Metadata Document support and the provider has + # a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration. + # + # The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in. + # A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support, + # otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against + # a server that has not actually adopted it. + # + # The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support + # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise + # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata + # and the current `provider.client_id_metadata_document_url`. + cimd_url = @provider.client_id_metadata_document_url + if cimd_url && as_metadata["client_id_metadata_document_supported"] == true + return { "client_id" => cimd_url } + end + registration_endpoint = as_metadata["registration_endpoint"] unless registration_endpoint raise AuthorizationError, diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 211f496..139c07f 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -25,6 +25,16 @@ module MCP # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, # `client_information`, and `save_client_information(info)`. Defaults to # an `InMemoryStorage`. + # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document + # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification). + # When the authorization server advertises `client_id_metadata_document_supported: true`, + # the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + # Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments. + # The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include, + # but different encodings of the same query would yield different `client_id` strings for the same document). + # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword: + # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set + # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. class Provider # Raised when `Provider#initialize` is called with a `redirect_uri` that # is neither HTTPS nor a loopback `http://` URL, per the MCP @@ -38,12 +48,21 @@ module MCP # runtime; failing at construction surfaces the bug earlier. class UnregisteredRedirectURIError < ArgumentError; end + # Raised when `client_id_metadata_document_url` is provided but does not meet + # the structural requirements for a Client ID Metadata Document URL: + # HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments. + # The CIMD URL is sent to the authorization server as the OAuth `client_id`, + # so the same Communication Security guarantee that protects the redirect URI + # applies and the value must unambiguously identify the document. + class InvalidClientIDMetadataDocumentURLError < ArgumentError; end + attr_reader :client_metadata, :redirect_uri, :scope, :storage, :redirect_handler, - :callback_handler + :callback_handler, + :client_id_metadata_document_url def initialize( client_metadata:, @@ -51,7 +70,8 @@ module MCP redirect_handler:, callback_handler:, scope: nil, - storage: nil + storage: nil, + client_id_metadata_document_url: nil ) unless Discovery.secure_url?(redirect_uri) raise InsecureRedirectURIError, @@ -66,12 +86,20 @@ module MCP "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request." end + if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url) + raise InvalidClientIDMetadataDocumentURLError, + "client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \ + "with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \ + "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." + end + @client_metadata = client_metadata @redirect_uri = redirect_uri @redirect_handler = redirect_handler @callback_handler = callback_handler @scope = scope @storage = storage || InMemoryStorage.new + @client_id_metadata_document_url = client_id_metadata_document_url end def access_token diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index 018957e..4dbd1c3 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -367,6 +367,68 @@ module MCP refute(Discovery.secure_url?("not a url")) end + def test_client_id_metadata_document_url_accepts_https_with_path + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com:8443/cm")) + end + + def test_client_id_metadata_document_url_accepts_boundary_forms + # An uppercase scheme is `https` after case folding (URIs are scheme-insensitive, RFC 3986 Section 3.1). + assert(Discovery.client_id_metadata_document_url?("HTTPS://app.example.com/cm")) + + # A trailing slash and consecutive slashes are non-root, non-dot paths. + # RFC 3986 `remove_dot_segments` does not collapse them, so they remain + # stable `client_id` strings rather than aliasing another URL. + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com//cm")) + + # The default https port is equivalent to omitting it; either form is a valid origin. + assert(Discovery.client_id_metadata_document_url?("https://app.example.com:443/cm")) + + # IPv6 literal hosts and Punycode (IDN) hosts are ordinary hosts. + assert(Discovery.client_id_metadata_document_url?("https://[2001:db8::1]/cm")) + assert(Discovery.client_id_metadata_document_url?("https://[::1]/cm")) + assert(Discovery.client_id_metadata_document_url?("https://xn--e1afmkfd.example.com/cm")) + end + + def test_client_id_metadata_document_url_rejects_non_https_schemes + refute(Discovery.client_id_metadata_document_url?("http://localhost/cm")) + refute(Discovery.client_id_metadata_document_url?("http://app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("ftp://app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("file:///cm")) + end + + def test_client_id_metadata_document_url_rejects_root_and_empty_path + refute(Discovery.client_id_metadata_document_url?("https://app.example.com")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/")) + end + + def test_client_id_metadata_document_url_rejects_fragment_query_userinfo + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm#frag")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm?x=1")) + refute(Discovery.client_id_metadata_document_url?("https://user:pass@app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("https://user@app.example.com/cm")) + end + + def test_client_id_metadata_document_url_rejects_dot_segments + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/./cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/a/../cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/.")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/..")) + # Percent-encoded `.` would otherwise let `%2E` and `.` produce + # different `client_id` values for the same document. + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2E/cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2e/cm")) + end + + def test_client_id_metadata_document_url_rejects_nil_or_empty_or_malformed + refute(Discovery.client_id_metadata_document_url?(nil)) + refute(Discovery.client_id_metadata_document_url?("")) + refute(Discovery.client_id_metadata_document_url?("not a url")) + refute(Discovery.client_id_metadata_document_url?("https://")) + end + def test_resource_covers_blocks_percent_encoded_dot_segment_bypass server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp") prm = Discovery.canonicalize_url("https://srv.example.com/api") diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5da4ea8..98eb196 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -547,6 +547,276 @@ module MCP assert_not_requested(:post, "#{@auth_base}/register") end + def test_run_skips_dcr_when_as_supports_cimd_and_provider_has_cimd_url + # When the AS advertises Client ID Metadata Document support and the provider was configured with a CIMD URL, + # the SDK uses the URL as the OAuth `client_id` and does not call /register. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + captured_authorization_url = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://app.example.com/client-metadata.json", query["client_id"]) + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["client_id"] == "https://app.example.com/client-metadata.json" + end + # The CIMD URL is NOT persisted to storage: AS metadata is re-read on every flow, + # so a later AS that no longer advertises CIMD will not be sent a stale CIMD `client_id`. + assert_nil(provider.client_information) + end + + def test_run_falls_back_to_dcr_when_provider_has_cimd_url_but_as_does_not_advertise_support + # The provider may carry a CIMD URL across multiple servers; if a particular AS does not advertise CIMD support, + # the SDK must still register via DCR rather than send an unsupported `client_id`. + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", provider.client_information["client_id"]) + end + + def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_false + # A misconfigured AS that ships `"client_id_metadata_document_supported": "false"` + # is truthy in Ruby. The SDK must NOT route through CIMD on truthy non-boolean values; + # only a JSON `boolean true` opts in. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "false", + ), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", provider.client_information["client_id"]) + end + + def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_true + # Mirror of the previous test for the other truthy non-boolean value: + # `"true"` is also a string, not the JSON boolean the spec requires. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "true", + ), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + end + + def test_run_does_not_persist_cimd_client_id_so_later_flow_recovers_when_as_drops_support + # Regression for the storage side-effect: if the SDK persisted the CIMD URL on the first flow, + # a second flow against an AS that no longer advertises CIMD support would still send the URL as + # the `client_id` and skip DCR. Re-running with the same provider must honour the updated AS metadata. + shared_storage = InMemoryStorage.new + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + storage: shared_storage, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + # First flow: AS supports CIMD, so DCR is skipped. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + assert_nil(shared_storage.client_information, "CIMD client_id must not be persisted") + + # Second flow: AS no longer advertises CIMD. The SDK must register via DCR rather than reuse a stale CIMD URL. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", shared_storage.client_information["client_id"]) + end + + def test_run_skips_cimd_when_pre_registered_client_information_exists + # Pre-registered `client_information` wins over CIMD: the user explicitly chose a DCR-style identity, + # so neither DCR nor the CIMD URL should overwrite it. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + captured_authorization_url = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("preregistered-client", query["client_id"]) + end + def test_run_uses_basic_auth_when_token_endpoint_auth_method_is_client_secret_basic state_holder = {} provider = Provider.new( @@ -876,6 +1146,170 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end + def test_refresh_uses_cimd_url_as_client_id_when_provider_has_cimd_and_as_supports_it + # Regression: the CIMD branch in `ensure_client_registered` does not persist `client_information`, + # so a refresh call that requires stored client info would always fail after a CIMD-only flow. + # `refresh!` must reconstruct the CIMD `client_id` from live AS metadata + the provider URL on every call. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including( + "grant_type" => "refresh_token", + "refresh_token" => "saved-rt", + "client_id" => "https://app.example.com/client-metadata.json", + ), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + assert_nil(provider.client_information, "refresh must not persist a CIMD client_information entry") + end + + def test_refresh_raises_when_provider_has_cimd_url_but_as_no_longer_advertises_support + # If the AS later drops CIMD support and the provider has neither a stored `client_information` nor + # an alternative identity, refresh has no way to authenticate. Fail loudly rather than send a CIMD + # `client_id` the AS no longer recognises. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_id_metadata_document_supported/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_prefers_stored_client_information_over_cimd_url + # Even when both are available, an explicitly stored `client_information` wins. + # CIMD MUST NOT silently override it. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including( + "grant_type" => "refresh_token", + "client_id" => "preregistered-client", + ), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_client_information("client_id" => "preregistered-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("fresh-at", provider.access_token) + end + + def test_refresh_raises_when_cimd_support_advertised_as_string_true + # Mirror of the `run!` strict-boolean tests on the refresh path: a truthy non-boolean value + # such as the string `"true"` must NOT be treated as CIMD support. With no stored `client_information` + # and no genuine CIMD support, refresh has no identity to authenticate with. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "true", + ), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_id_metadata_document_supported/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_refresh_raises_when_no_refresh_token provider = Provider.new( client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index ed4db35..2b17ed5 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -83,6 +83,104 @@ module MCP provider = Provider.new(**args) assert_equal("http://localhost:0/callback", provider.redirect_uri) end + + def test_initialize_defaults_client_id_metadata_document_url_to_nil + provider = Provider.new(**args_for("http://localhost:0/callback")) + + assert_nil(provider.client_id_metadata_document_url) + end + + def test_initialize_accepts_https_client_id_metadata_document_url + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + provider = Provider.new(**args) + assert_equal( + "https://app.example.com/client-metadata.json", + provider.client_id_metadata_document_url, + ) + end + + def test_initialize_rejects_http_client_id_metadata_document_url + # The loopback `http` carve-out used for discovery does not extend to the CIMD URL: + # the URL is sent to the AS as the OAuth `client_id` and travels off-loopback, + # so MCP requires `https`. + ["http://localhost:8080/client-metadata.json", "http://app.example.com/cm"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_non_https_scheme_client_id_metadata_document_url + ["ftp://app.example.com/cm", "file:///cm", "foo://bar/cm"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_client_id_metadata_document_url_without_path + # The CIMD document is a discrete resource, not the origin. + # A bare origin or root-path URL would let two different deployments at + # the same origin shadow each other under one `client_id`. + ["https://app.example.com", "https://app.example.com/"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_fragment + # A fragment is never sent to the server; embedding one in the `client_id` value + # would make the OAuth `client_id` and the URL the AS dereferences point at different artifacts. + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/cm#frag", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_query + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/cm?x=1", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_userinfo + # Embedding credentials in the `client_id` leaks them to every AS + # log line and to the conformance audit trail. + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://user:pass@app.example.com/cm", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_dot_segments + # `https://x/a/./b` and `https://x/a/b` are RFC 3986 equivalent, + # so accepting both would let one client publish under two `client_id` values. + # Reject `.`/`..` (including percent-encoded `.`). + [ + "https://app.example.com/./cm", + "https://app.example.com/a/../cm", + "https://app.example.com/%2E/cm", + ].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end end end end From 5c8b132dcdc0bfeed978167889535632f0b07ad0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 27 May 2026 03:21:23 +0900 Subject: [PATCH 085/247] Cache `Tool::Schema` Validation to Avoid Re-validating Identical Schemas ## Motivation and Context `MCP::Tool::Schema#initialize` validated every instance against the draft-04 metaschema via `JSON::Validator.fully_validate`, costing ~32ms for deep schemas (up to ~100ms). When schemas are built dynamically and repeatedly, this work is wasted because the result depends only on the schema content. This caches successful validations by a content digest, so an identical schema is validated once and subsequent constructions skip the traversal (~100ms to ~0.1ms on a cache hit). The cache is bounded and thread-safe. It supersedes the `validate: false` escape hatch proposed in #362, which would have added permanent public interface and allowed invalid schemas to reach clients. Python and TypeScript SDKs do not metaschema-validate schema definitions, so this cost is specific to the Ruby SDK; caching reduces it without weakening the default or changing validation semantics. ## How Has This Been Tested? Added regression tests in `test/mcp/tool/schema_test.rb`: identical schemas validate only once, distinct schemas validate separately, a cache hit still yields a usable and correctly validated schema, invalid schemas raise on every construction and are not cached, a schema at the normalization depth limit is cached without a nesting error, and `ValidationCache` evicts the oldest entry beyond its max size. The full suite (`rake test`) and `rake rubocop` pass. ## Breaking Changes None. Validation safety is preserved: every distinct schema is still validated, and invalid schemas continue to raise. The initializer signature and validation semantics are unchanged. --- lib/mcp/tool/schema.rb | 43 +++++++++++++++- test/mcp/tool/schema_test.rb | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 test/mcp/tool/schema_test.rb diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb index 98a137a..820a760 100644 --- a/lib/mcp/tool/schema.rb +++ b/lib/mcp/tool/schema.rb @@ -1,10 +1,41 @@ # frozen_string_literal: true +require "digest" require "json-schema" module MCP class Tool class Schema + # Metaschema validation depends only on schema content, so a given schema + # never needs to be validated more than once. Caching the result lets repeated + # (e.g. dynamically rebuilt) schemas skip the costly traversal. + class ValidationCache + DEFAULT_MAX_SIZE = 1000 + + def initialize(max_size: DEFAULT_MAX_SIZE) + @max_size = max_size + @entries = {} + @mutex = Mutex.new + end + + def validated?(key) + @mutex.synchronize { @entries.key?(key) } + end + + def store(key) + @mutex.synchronize do + @entries.delete(key) + @entries[key] = true + @entries.shift while @entries.size > @max_size + end + end + + def clear + @mutex.synchronize { @entries.clear } + end + end + VALIDATION_CACHE = ValidationCache.new + # JSON Schema 2020-12 is the default dialect for MCP schema definitions # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation # is still performed against the JSON Schema draft-04 metaschema because @@ -36,6 +67,14 @@ module MCP end def validate_schema! + target = schema_for_validation + + # `max_nesting: false` because normalization uses `JSON.dump` (no nesting limit), + # so the default `JSON.generate` limit would raise on a deeply nested schema that + # the initializer already accepted. + key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false)) + return if VALIDATION_CACHE.validated?(key) + gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path) schema_reader = JSON::Schema::Reader.new( accept_uri: false, @@ -45,10 +84,12 @@ module MCP # Converts metaschema to a file URI for cross-platform compatibility metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/")) metaschema = metaschema_uri.to_s - errors = JSON::Validator.fully_validate(metaschema, schema_for_validation, schema_reader: schema_reader) + errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader) if errors.any? raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" end + + VALIDATION_CACHE.store(key) end # The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema` diff --git a/test/mcp/tool/schema_test.rb b/test/mcp/tool/schema_test.rb new file mode 100644 index 0000000..48d93d8 --- /dev/null +++ b/test/mcp/tool/schema_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Tool + class SchemaTest < ActiveSupport::TestCase + setup do + Schema::VALIDATION_CACHE.clear + end + + test "validates a schema once and reuses the result for identical schemas" do + JSON::Validator.expects(:fully_validate).once.returns([]) + + schema = { properties: { validates_once: { type: "string" } } } + InputSchema.new(schema) + InputSchema.new(schema) + end + + test "validates distinct schemas separately" do + JSON::Validator.expects(:fully_validate).twice.returns([]) + + InputSchema.new(properties: { distinct_a: { type: "string" } }) + InputSchema.new(properties: { distinct_b: { type: "string" } }) + end + + test "a cache hit still yields a usable, validated schema" do + schema = { properties: { cache_hit: { type: "string" } }, required: ["cache_hit"] } + InputSchema.new(schema) + cached = InputSchema.new(schema) + + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { cache_hit: { type: "string" } }, + required: ["cache_hit"], + }, + cached.to_h, + ) + assert_nil(cached.validate_arguments(cache_hit: "value")) + assert_raises(InputSchema::ValidationError) do + cached.validate_arguments(cache_hit: 123) + end + end + + test "an invalid schema raises every time and is not cached" do + invalid = { properties: { not_cached: { type: "invalid_type" } } } + + assert_raises(ArgumentError) { InputSchema.new(invalid) } + assert_raises(ArgumentError) { InputSchema.new(invalid) } + end + + test "a schema at the normalization depth limit is cached without a nesting error" do + # The deepest schema the initializer can still normalize via JSON.dump/parse. + # The cache key must tolerate the same depth; the default JSON.generate + # nesting limit (100) is stricter than normalization and would raise here. + schema = { properties: { leaf: { type: "string" } } } + loop do + candidate = { properties: { child: schema } } + JSON.parse(JSON.dump(candidate)) + schema = candidate + rescue JSON::NestingError + break + end + + JSON::Validator.stub(:fully_validate, []) do + assert_nothing_raised do + InputSchema.new(schema) + InputSchema.new(schema) + end + end + end + + test "ValidationCache evicts the oldest entry beyond its max size" do + cache = Schema::ValidationCache.new(max_size: 2) + cache.store("a") + cache.store("b") + cache.store("c") + + refute cache.validated?("a") + assert cache.validated?("b") + assert cache.validated?("c") + end + + test "ValidationCache#clear empties the cache" do + cache = Schema::ValidationCache.new + cache.store("a") + cache.clear + + refute cache.validated?("a") + end + end + end +end From fdca6f5a2fc1b2449b45f084f49badb5ff854099 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 26 May 2026 19:11:29 +0900 Subject: [PATCH 086/247] Request offline_access scope when supported (SEP-2207) ## Motivation and Context SEP-2207 describes how an OAuth client obtains refresh tokens: it declares the `refresh_token` grant type in its client metadata and requests the `offline_access` scope, but only when the authorization server advertises `offline_access` in its metadata `scopes_supported`. Requesting `offline_access` when the server does not list it is forbidden. The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never requested `offline_access`, so it could not obtain a refresh token from a server that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs and makes the `auth/offline-access-scope` conformance scenario pass (previously it reported one warning, `sep-2207-client-metadata-grant-types`, and was listed in the expected-failures baseline). The change adds: - `Flow#augment_scope_with_offline_access`, called from `run!` after `resolve_scope`, appends `offline_access` to the requested scope when both the client opted into refresh tokens (its registered `grant_types` include `refresh_token`) and the authorization server advertises `offline_access` in its metadata `scopes_supported`. Already-present `offline_access` is not duplicated. Gating on the server advertisement keeps the SDK from ever requesting the scope where it is not supported. - `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection of the SDK's refresh support, which clears the SEP-2207 grant-types warning. - `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New `Flow` tests cover: `offline_access` is requested when the server advertises it and the client declared the `refresh_token` grant; `offline_access` is NOT requested when the client did not declare the grant; `offline_access` is NOT requested when the server does not advertise it (even with the grant declared); and `offline_access` is not duplicated when it is already part of the resolved scope. Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings, and `auth/offline-access-not-supported` still passes 13/13 (no regression). `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. `offline_access` is appended only when the client's own `grant_types` opt into refresh tokens and the authorization server advertises the scope, so existing clients that do neither see no change in the scope they request. --- README.md | 3 + conformance/client.rb | 2 +- conformance/expected_failures.yml | 1 - lib/mcp/client/oauth/flow.rb | 43 +++++++ test/mcp/client/oauth/flow_test.rb | 182 +++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81881a7..9b7d597 100644 --- a/README.md +++ b/README.md @@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). +- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata + `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise + `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. ```ruby require "mcp" diff --git a/conformance/client.rb b/conformance/client.rb index f1efe61..c08c049 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -82,7 +82,7 @@ def build_oauth_provider(context) client_metadata: { client_name: "ruby-sdk-conformance-client", redirect_uris: [redirect_uri], - grant_types: ["authorization_code"], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }, diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index acac417..ca3179f 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -12,4 +12,3 @@ client: - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow - - auth/offline-access-scope diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 3708a48..b770363 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -59,6 +59,7 @@ module MCP client_info = ensure_client_registered(as_metadata: as_metadata) effective_scope = resolve_scope(scope: scope, prm: prm) + effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) @@ -403,6 +404,48 @@ module MCP nil end + # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves: + # + # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing + # `refresh_token` in its registered `grant_types`) MAY request `offline_access` + # when the authorization server advertises it in metadata `scopes_supported`. + # When the server advertises it and the client opted in, add it if absent. + # + # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`, + # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge, + # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT + # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge, + # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into + # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's + # own request consistent with the AS's advertisement. + # + # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207 + def normalize_offline_access_scope(scope, as_metadata:) + scopes = scope.to_s.split + + if server_supports_offline_access?(as_metadata) + scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access") + else + scopes.delete("offline_access") + end + + scopes.empty? ? nil : scopes.join(" ") + end + + def server_supports_offline_access?(as_metadata) + supported = as_metadata["scopes_supported"] + + supported.is_a?(Array) && supported.include?("offline_access") + end + + def wants_refresh_token? + metadata = @provider.client_metadata + grant_types = metadata[:grant_types] || metadata["grant_types"] + + Array(grant_types).include?("refresh_token") + end + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5da4ea8..2b6d22a 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,6 +68,34 @@ module MCP WebMock.reset! end + # Runs the full authorization flow and returns the `scope` query parameter + # sent on the authorization request. The caller stubs the AS metadata; + # this helper supplies a provider whose `grant_types` and optional pre-set + # `scope` drive the SEP-2207 offline_access decision. + def capture_authorization_scope(grant_types:, provider_scope: nil) + captured_scope = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: grant_types, + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + query = URI.decode_www_form(url.query).to_h + captured_scope = query["scope"] + state_holder[:state] = query.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + scope: provider_scope, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + captured_scope + end + def test_run_completes_full_authorization_flow captured_authorization_url = nil state_value = nil @@ -112,6 +140,160 @@ module MCP end end + def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared + # SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access` + # when the AS advertises it, so it can obtain a refresh token. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + assert_includes(captured.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared + # The AS advertises offline_access, but the client did not opt into refresh tokens, + # so the scope is not requested. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_server_does_not_advertise_it + # SEP-2207 forbids requesting offline_access when the AS does not list it, + # even if the client declared the refresh_token grant type. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "mcp:read"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it + # SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope + # (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope. + # SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's + # defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access` + # even though the AS has not opted in. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + refute_includes(captured.to_s.split, "offline_access") + assert_includes(captured.to_s.split, "mcp:basic") + end + + def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it + # When stripping leaves an empty scope, no `scope` parameter is sent. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "offline_access", + ) + + assert_nil(captured) + end + + def test_run_does_not_duplicate_offline_access_already_in_scope + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + assert_equal(1, captured.split.count("offline_access")) + end + def test_run_raises_on_state_mismatch provider = Provider.new( client_metadata: { From 39882919b899a8a4d9a264da2185c52e7b3055dc Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 30 May 2026 03:18:34 +0900 Subject: [PATCH 087/247] Release 0.18.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 15 +++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3dd6e..afed492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0] - 2026-05-30 + +### Added + +- Support server-to-client `ping` per MCP specification (#358) + +### Changed + +- Warn on implicit stdio initialization (#338) +- Cache `Tool::Schema` validation to avoid re-validating identical schemas (#363) + +### Fixed + +- Fix case-sensitive `Accept` header comparison (#359) + ## [0.17.0] - 2026-05-19 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index ff392d2..eea2cb4 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.17.0" + VERSION = "0.18.0" end From 9d4dcbf1a4ff272f27e1a27e6b23ce2c507f7ac4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 1 Jun 2026 21:45:26 +0900 Subject: [PATCH 088/247] Speed Up `Tool::Schema` Validation by 5x to 100x ## Motivation and Context `MCP::Tool::Schema` previously used the pure-Ruby `json-schema` gem for both construction-time metaschema validation and runtime argument / result validation. For deep schemas this cost ~32-100ms per construction (see issue #364). `json_schemer` (https://rubygems.org/gems/json_schemer, v2.5.0) is an actively maintained alternative that is much faster on this workload. Switching reduces cold-start warming pressure in horizontally scaled multi-tenant deployments (the scenario raised in #364) and brings runtime data validation down to sub-millisecond per call once the per-instance schemer is memoized. The implementation preserves the legacy behavior of the `json-schema` based code. Metaschema is pinned to draft-04 (`meta_schema:` option); the `$schema` dialect URI is still emitted in `to_h` as 2020-12 but runtime validation continues to use draft-04, exactly as before. Moving the runtime validator itself to 2020-12 is out of scope for this PR and warrants its own discussion. `format` keywords are not enforced (`format: false` option), matching what `json-schema`'s draft-04 path did. Malformed schemas (e.g. an invalid `pattern` regex) continue to surface as `ArgumentError, "Invalid JSON Schema: ..."`; the `RegexpError` `json_schemer` raises during construction is wrapped. `Symbol` values in argument data are coerced to strings inside the internal `stringify` helper so they validate against `type: "string"` the same way they did before. The public `Schema#schema` accessor is dropped. It was a refactor artifact from #198 (the consolidation of `InputSchema`'s `properties` / `required` readers into a single hash) with no callers in `lib/`; tests that needed the merged hash now read it through `to_h`. The accessor's only remaining role would have been to expose a mutation path that could desynchronize the memoized `@schemer`, which this change removes. Runtime dependency delta: drops `json-schema` (and `addressable`), adds `json_schemer`, `hana`, `regexp_parser`, and `simpleidn`. Each added gem is a single-purpose library tied to JSON Schema spec compliance. Closes #364. ## How Has This Been Tested? Side-by-side benchmark on representative schemas (simple, with `$ref` / `additionalProperties`, nested, depth 20, depth 40): construction-time metaschema validation is 4.7x to ~100x faster; runtime data validation with the memoized schemer is sub-millisecond across all sizes. Four existing tests had their stub targets or message assertions updated to track the new validator while preserving the original test intent. The "unexpected errors bubble up" tests in `input_schema_test.rb` and `output_schema_test.rb` now stub `JSONSchemer::Schema#validate` instead of `JSON::Validator.fully_validate`. The cache tests in `schema_test.rb` now stub `JSONSchemer::Schema#validate_schema`. The two "detailed error message" tests in `tool_test.rb` switched from asserting `json-schema`'s exact wording to format-agnostic substrings (`"properties/count/minimum"`, `"number"`). The "required arguments are converted to strings" test in `input_schema_test.rb` now reads the result through `to_h[:required]` instead of the removed `schema` accessor. Regression tests added for the legacy-behavior preservations above: `format` keyword is not enforced, invalid `pattern` raises `ArgumentError` (not `RegexpError`), and `Symbol` values validate against `type: "string"`. ## Breaking Changes `Schema#schema` is no longer a public method. The accessor had no callers outside the test suite, but anyone reading the internal representation directly (rather than through `to_h`) will need to switch. `Schema` / `InputSchema` / `OutputSchema` otherwise keep their constructor signatures, `to_h` output, `ValidationError` / `ArgumentError` raise points, and the `$schema` dialect emission. The validator error message wording inside `ArgumentError` and `ValidationError` now comes from `json_schemer` and still includes the JSON pointer path and a description of the mismatch. --- lib/mcp/tool/input_schema.rb | 4 +- lib/mcp/tool/schema.rb | 57 +++++++++++++++++++---------- mcp.gemspec | 2 +- test/mcp/tool/input_schema_test.rb | 30 ++++++++++++--- test/mcp/tool/output_schema_test.rb | 8 ++-- test/mcp/tool/schema_test.rb | 14 +++---- test/mcp/tool_test.rb | 8 ++-- 7 files changed, 81 insertions(+), 42 deletions(-) diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 724f9bb..1fca775 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -12,9 +12,9 @@ module MCP end def missing_required_arguments(arguments) - return [] unless schema[:required].is_a?(Array) + return [] unless @schema[:required].is_a?(Array) - (schema[:required] - arguments.keys.map(&:to_s)) + (@schema[:required] - arguments.keys.map(&:to_s)) end def validate_arguments(arguments) diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb index 820a760..e71a8d8 100644 --- a/lib/mcp/tool/schema.rb +++ b/lib/mcp/tool/schema.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "digest" -require "json-schema" +require "json_schemer" module MCP class Tool @@ -38,11 +38,10 @@ module MCP # JSON Schema 2020-12 is the default dialect for MCP schema definitions # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation - # is still performed against the JSON Schema draft-04 metaschema because - # the `json-schema` gem does not yet support 2020-12. + # is still performed against the JSON Schema draft-04 metaschema. JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema" - attr_reader :schema + DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#" def initialize(schema = {}) @schema = JSON.parse(JSON.dump(schema), symbolize_names: true) @@ -51,7 +50,7 @@ module MCP end def ==(other) - other.is_a?(self.class) && schema == other.schema + other.is_a?(self.class) && @schema == other.instance_variable_get(:@schema) end def to_h @@ -62,8 +61,38 @@ module MCP private + def stringify(obj) + case obj + when Hash + obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) } + when Array + obj.map { |v| stringify(v) } + when Symbol + obj.to_s + else + obj + end + end + + # Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost. + # Memoized per Schema instance because schema content is fixed at construction, + # so the compiled schemer is reusable across many `fully_validate` calls. + # + # `format: false` preserves the legacy behavior of the previous `json-schema` based implementation, + # which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as + # `ArgumentError` so callers see the same exception class they used to. + def schemer + @schemer ||= JSONSchemer.schema( + stringify(schema_for_validation), + meta_schema: DRAFT4_META_SCHEMA_URI, + format: false, + ) + rescue RegexpError => e + raise ArgumentError, "Invalid JSON Schema: #{e.message}" + end + def fully_validate(data) - JSON::Validator.fully_validate(schema_for_validation, data) + schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") } end def validate_schema! @@ -75,16 +104,7 @@ module MCP key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false)) return if VALIDATION_CACHE.validated?(key) - gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path) - schema_reader = JSON::Schema::Reader.new( - accept_uri: false, - accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) }, - ) - metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema) - # Converts metaschema to a file URI for cross-platform compatibility - metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/")) - metaschema = metaschema_uri.to_s - errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader) + errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") } if errors.any? raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" end @@ -92,9 +112,8 @@ module MCP VALIDATION_CACHE.store(key) end - # The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema` - # dialect URIs. Strip the top-level `$schema` before validation so a dialect URI - # (whether SDK-injected by `to_h` or user-supplied) does not break the validator. + # `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation: + # this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas. def schema_for_validation return @schema unless @schema.key?(:"$schema") diff --git a/mcp.gemspec b/mcp.gemspec index 4c44f49..9c4d602 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -30,5 +30,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency("json-schema", ">= 4.1") + spec.add_dependency("json_schemer", ">= 2.4") end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 80a5782..972c843 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -7,7 +7,7 @@ module MCP class InputSchemaTest < ActiveSupport::TestCase test "required arguments are converted to strings" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) - assert_equal ["message"], input_schema.schema[:required] + assert_equal ["message"], input_schema.to_h[:required] end test "to_h returns a hash representation of the input schema" do @@ -139,10 +139,10 @@ module MCP test "unexpected errors bubble up from validate_arguments" do schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do - assert_raises(RuntimeError) do - schema.validate_arguments({ foo: "bar" }) - end + JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error") + + assert_raises(RuntimeError) do + schema.validate_arguments(foo: "bar") end end @@ -200,6 +200,26 @@ module MCP schema6 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false) refute_equal schema1, schema6 end + + test "format keyword is not enforced (legacy behavior)" do + schema = InputSchema.new( + properties: { email: { type: "string", format: "email" } }, + required: ["email"], + ) + assert_nil(schema.validate_arguments(email: "not_an_email")) + end + + test "invalid pattern raises ArgumentError, not RegexpError" do + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { id: { type: "string", pattern: "[" } }) + end + assert_includes error.message, "Invalid JSON Schema" + end + + test "Symbol values in arguments are treated as strings" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + assert_nil(schema.validate_arguments(foo: :bar)) + end end end end diff --git a/test/mcp/tool/output_schema_test.rb b/test/mcp/tool/output_schema_test.rb index 073de2c..5403f00 100644 --- a/test/mcp/tool/output_schema_test.rb +++ b/test/mcp/tool/output_schema_test.rb @@ -110,10 +110,10 @@ module MCP test "unexpected errors bubble up from validate_result" do schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do - assert_raises(RuntimeError) do - schema.validate_result({ foo: "bar" }) - end + JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error") + + assert_raises(RuntimeError) do + schema.validate_result(foo: "bar") end end diff --git a/test/mcp/tool/schema_test.rb b/test/mcp/tool/schema_test.rb index 48d93d8..edc90e3 100644 --- a/test/mcp/tool/schema_test.rb +++ b/test/mcp/tool/schema_test.rb @@ -10,7 +10,7 @@ module MCP end test "validates a schema once and reuses the result for identical schemas" do - JSON::Validator.expects(:fully_validate).once.returns([]) + JSONSchemer::Schema.any_instance.expects(:validate_schema).once.returns([]) schema = { properties: { validates_once: { type: "string" } } } InputSchema.new(schema) @@ -18,7 +18,7 @@ module MCP end test "validates distinct schemas separately" do - JSON::Validator.expects(:fully_validate).twice.returns([]) + JSONSchemer::Schema.any_instance.expects(:validate_schema).twice.returns([]) InputSchema.new(properties: { distinct_a: { type: "string" } }) InputSchema.new(properties: { distinct_b: { type: "string" } }) @@ -64,11 +64,11 @@ module MCP break end - JSON::Validator.stub(:fully_validate, []) do - assert_nothing_raised do - InputSchema.new(schema) - InputSchema.new(schema) - end + JSONSchemer::Schema.any_instance.stubs(:validate_schema).returns([]) + + assert_nothing_raised do + InputSchema.new(schema) + InputSchema.new(schema) end end diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index e32df0a..bdec7b6 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -167,8 +167,8 @@ module MCP end assert_includes error.message, "Invalid JSON Schema" - assert_includes error.message, "#/properties/count/minimum" - assert_includes error.message, "string did not match the following type: number" + assert_includes error.message, "properties/count/minimum" + assert_includes error.message, "number" end test ".define allows definition of simple tools with a block" do @@ -431,8 +431,8 @@ module MCP end assert_includes error.message, "Invalid JSON Schema" - assert_includes error.message, "#/properties/count/minimum" - assert_includes error.message, "string did not match the following type: number" + assert_includes error.message, "properties/count/minimum" + assert_includes error.message, "number" end test "output_schema accepts $ref in schema" do From 8c911304519ce0e89a4fd1ae9e58730b322b9c3f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 11 Apr 2026 13:54:00 +0900 Subject: [PATCH 089/247] [Doc] Fix `server_context` description for Exception Reporting ## Motivation and Context The README's Exception Reporting section claimed that `server_context` includes `{ tool_name: "name", arguments: { ... } }` for tool calls. This matched the implementation in 0.3.0 through 0.5.0, but the `{ tool_name:, arguments: }` context was removed in 0.5.1 when exception reporting was consolidated into the centralized `{ request: request }` path. The README was never updated to reflect this. `Server#report_exception` is now only called with `{ request: request }` (from `handle_request`) or `{ notification: "..." }` (from notification delivery rescues), and tool call failures flow through the `{ request: ... }` path after being wrapped in a `RequestHandlerError`. The stale description misled users into expecting `tool_name` and `arguments` in their exception reporter callbacks. ## How Has This Been Tested? Documentation-only change. No behavioral modification. ## Breaking Changes None. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bdf0bd7..30dd409 100644 --- a/README.md +++ b/README.md @@ -423,10 +423,10 @@ The exception reporter receives two arguments: - `exception`: The Ruby exception object that was raised - `server_context`: A hash containing contextual information about where the error occurred -The server_context hash includes: +The `server_context` hash includes: -- For tool calls: `{ tool_name: "name", arguments: { ... } }` -- For general request handling: `{ request: { ... } }` +- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) +- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) When an exception occurs: From c91702899deb58fa7ee99ad68665a6ebf9319997 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 01:28:17 +0900 Subject: [PATCH 090/247] Use JSON-RPC error envelope for `StreamableHTTPTransport` errors ## Motivation and Context PR #347 introduced a single JSON-RPC error envelope response for unsupported `MCP-Protocol-Version` headers, while the rest of the transport-level error responses (Accept, Content-Type, Invalid JSON, session management, method not allowed, internal server error) still returned plain JSON of the form `{ "error": "..." }`. The Python SDK (`src/mcp/server/streamable_http.py`) and TypeScript SDK (`packages/server/src/server/streamableHttp.ts`) consistently use a JSON-RPC error envelope for all transport-level errors. Unify the Ruby SDK with them. ## Behavior All transport-level error responses now return a JSON-RPC error envelope: ```json { "jsonrpc": "2.0", "id": null, "error": { "code": -32600, "message": "..." } } ``` Affected helpers in `lib/mcp/server/transports/streamable_http_transport.rb`: - `validate_content_type` (HTTP 415) - `not_acceptable_response` (HTTP 406) - `parse_request_body` error (HTTP 400, `PARSE_ERROR` `-32700`) - `method_not_allowed_response` (HTTP 405) - `missing_session_id_response` (HTTP 400) - `session_not_found_response` (HTTP 404) - `session_already_connected_response` (HTTP 409) - Internal server error fallback in `handle_post` (HTTP 500, `INTERNAL_ERROR` `-32603`) - `validate_protocol_version_header` (HTTP 400, dedup with shared helper) A new private helper `json_rpc_error_response(status:, code:, message:)` centralizes the envelope construction. The "Invalid JSON" message wording is updated to "Parse error: Invalid JSON" to match the JSON-RPC 2.0 `PARSE_ERROR` semantics. ## How Has This Been Tested? Updated all affected tests in `test/mcp/server/transports/streamable_http_transport_test.rb` to assert the new envelope shape (`body["error"]["message"]` instead of `body["error"]`). ## Breaking Changes Clients that parsed the previous plain `{"error": "..."}` shape will need to read `body["error"]["message"]` (or `body["error"]["code"]`). The HTTP status codes are unchanged, only the response body structure changed. --- .../transports/streamable_http_transport.rb | 79 +++++++++++------- .../streamable_http_transport_test.rb | 82 ++++++++++++------- 2 files changed, 103 insertions(+), 58 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 39950f3..57416f8 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -389,7 +389,11 @@ module MCP end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) - [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]] + json_rpc_error_response( + status: 500, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal server error", + ) end def handle_get(request) @@ -513,19 +517,19 @@ module MCP media_type = content_type&.split(";")&.first&.strip&.downcase return if media_type == "application/json" - [ - 415, - { "Content-Type" => "application/json" }, - [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json], - ] + json_rpc_error_response( + status: 415, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Unsupported Media Type: Content-Type must be application/json", + ) end def not_acceptable_response(required_types) - [ - 406, - { "Content-Type" => "application/json" }, - [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json], - ] + json_rpc_error_response( + status: 406, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}", + ) end def parse_request_body(body_string) @@ -535,7 +539,11 @@ module MCP end def invalid_json_response - [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::PARSE_ERROR, + message: "Parse error: Invalid JSON", + ) end def initialize_request?(body) @@ -548,15 +556,16 @@ module MCP return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ") - body = { - jsonrpc: "2.0", - id: nil, - error: { - code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, - message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}", - }, - } - [400, { "Content-Type" => "application/json" }, [body.to_json]] + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}", + ) + end + + def json_rpc_error_response(status:, code:, message:) + body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } + [status, { "Content-Type" => "application/json" }, [body.to_json]] end def notification?(body) @@ -793,15 +802,27 @@ module MCP end def method_not_allowed_response - [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]] + json_rpc_error_response( + status: 405, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Method not allowed", + ) end def missing_session_id_response - [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]] + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Missing session ID", + ) end def session_not_found_response - [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] + json_rpc_error_response( + status: 404, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Session not found", + ) end def already_initialized_response(request_id) @@ -821,11 +842,11 @@ module MCP end def session_already_connected_response - [ - 409, - { "Content-Type" => "application/json" }, - [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json], - ] + json_rpc_error_response( + status: 409, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Conflict: Only one SSE stream is allowed per session", + ) end def setup_sse_stream(session_id) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 20d094a..4813a6e 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -93,7 +93,10 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Invalid JSON", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, body["error"]["code"] + assert_equal "Parse error: Invalid JSON", body["error"]["message"] end test "POST request with JSON array body returns 400" do @@ -219,7 +222,10 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Session not found", body["error"]["message"] end test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do @@ -247,7 +253,7 @@ module MCP assert_equal(404, duplicate_response[0]) body = JSON.parse(duplicate_response[2][0]) - assert_equal("Session not found", body["error"]) + assert_equal("Session not found", body["error"]["message"]) refute(transport.send(:session_exists?, session_id), "expired session must be evicted") ensure @@ -451,7 +457,10 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Missing session ID", body["error"]["message"] end test "rejects POST request without session ID in stateful mode" do @@ -465,7 +474,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "rejects notification without session ID in stateful mode" do @@ -479,7 +488,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "rejects response without session ID in stateful mode" do @@ -493,7 +502,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "allows POST request without session ID in stateless mode" do @@ -537,7 +546,10 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"]["message"] end test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do @@ -579,7 +591,7 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "handles POST request with invalid session ID" do @@ -598,7 +610,7 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "handles DELETE request with valid session ID" do @@ -639,7 +651,7 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "POST returns 404 after session is deleted" do @@ -672,7 +684,7 @@ module MCP assert_equal 404, response[0] body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "DELETE returns 404 after session is already deleted" do @@ -702,7 +714,7 @@ module MCP assert_equal 404, response[0] body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "handles DELETE request with missing session ID" do @@ -717,7 +729,7 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "closes transport and cleans up session" do @@ -1317,7 +1329,10 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Method not allowed", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Method not allowed", body["error"]["message"] end test "POST request without Content-Type returns 415" do @@ -1332,7 +1347,10 @@ module MCP assert_equal 415, response[0] body = JSON.parse(response[2][0]) - assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"]["message"] end test "POST request with wrong Content-Type returns 415" do @@ -1379,7 +1397,7 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", - body["error"] + body["error"]["message"] end test "POST request with Accept header missing text/event-stream returns 406" do @@ -1398,7 +1416,7 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", - body["error"] + body["error"]["message"] end test "POST request with Accept header missing application/json returns 406" do @@ -1417,7 +1435,7 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", - body["error"] + body["error"]["message"] end test "POST request with valid Accept header succeeds" do @@ -1524,7 +1542,10 @@ module MCP assert_equal 406, response[0] body = JSON.parse(response[2][0]) - assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"] end test "GET request with Accept header missing text/event-stream returns 406" do @@ -1550,7 +1571,7 @@ module MCP assert_equal 406, response[0] body = JSON.parse(response[2][0]) - assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"] + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"] end test "GET request with valid Accept header succeeds" do @@ -1960,7 +1981,7 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Method not allowed", body["error"] + assert_equal "Method not allowed", body["error"]["message"] end test "stateless mode silently responds with success to session DELETE when session ID is not present" do @@ -2501,7 +2522,10 @@ module MCP assert_equal({ "Content-Type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Internal server error", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, body["error"]["code"] + assert_equal "Internal server error", body["error"]["message"] end test "send_request raises error in stateless mode" do @@ -3372,7 +3396,7 @@ module MCP assert_equal(404, response[0]) body = JSON.parse(response[2][0]) - assert_equal("Session not found", body["error"]) + assert_equal("Session not found", body["error"]["message"]) ensure transport.close end @@ -3407,7 +3431,7 @@ module MCP assert_equal(404, response[0]) body = JSON.parse(response[2][0]) - assert_equal("Session not found", body["error"]) + assert_equal("Session not found", body["error"]["message"]) ensure transport.close end @@ -3563,7 +3587,7 @@ module MCP assert_equal(404, response[0]) body = JSON.parse(response[2][0]) - assert_equal("Session not found", body["error"]) + assert_equal("Session not found", body["error"]["message"]) ensure transport.close end @@ -3756,7 +3780,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "POST response with invalid session ID returns 404" do @@ -3781,7 +3805,7 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] end test "handle_regular_request returns 404 for unknown session_id" do @@ -3797,7 +3821,7 @@ module MCP response = @transport.handle_request(request) assert_equal(404, response[0]) body = JSON.parse(response[2][0]) - assert_equal("Session not found", body["error"]) + assert_equal("Session not found", body["error"]["message"]) end test "session-scoped log notification is sent only to the originating session" do From f5507cc5aeba0f6b789a7d3bde59697b9d91c424 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 15 Mar 2026 04:00:13 +0900 Subject: [PATCH 091/247] [Doc] Add `logging/setLevel` to supported methods in README Follow-up to #103. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bdf0bd7..5056fcc 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ It implements the Model Context Protocol specification, handling model context r - `initialize` - Initializes the protocol and returns server capabilities - `ping` - Simple health check +- `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas - `tools/call` - Invokes a specific tool with provided arguments - `prompts/list` - Lists all registered prompts and their schemas From 8d123fb8f27e5b7e0afb0b7655339d879686e624 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 5 Jun 2026 01:24:25 +0900 Subject: [PATCH 092/247] [Doc] Clarify `server_context` Hash requirement for `_meta` merging ## Motivation and Context The README documents that request-level `_meta` becomes available to tools and prompts under `server_context[:_meta]`. However, `Server#server_context_with_meta` only merges `_meta` when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). When a non-`Hash` value is assigned to `server_context`, `_meta` is silently dropped and tools will not see it under `server_context[:_meta]`. This adds a note clarifying that `server_context` must be kept as a `Hash` if tools need access to `_meta`. ## How Has This Been Tested? Documentation-only change, no behavioral modification. ## Breaking Changes None. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index bdf0bd7..478fe82 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,11 @@ See the relevant sections below for the arguments they receive. The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. +> [!NOTE] +> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). +> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it +> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. + **Access Pattern:** When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: From 4322ec0886aa0b9fc5f24f8da96eeaea6bec9c19 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 7 Jun 2026 10:18:33 +0900 Subject: [PATCH 093/247] Default missing `MCP-Protocol-Version` to 2025-03-26 in StreamableHTTPTransport ## Motivation and Context MCP 2025-11-25 (basic/transports#protocol-version-header) specifies: > For backwards compatibility, if the server does not receive an > `MCP-Protocol-Version` header, and has no other way to identify the > version - for example, by relying on the protocol version negotiated > during initialization - the server SHOULD assume protocol version `2025-03-26`. PR #347 satisfied only the MUST requirement (reject invalid/unsupported values with 400). The SHOULD requirement to default a missing header to `2025-03-26` was deferred to this follow-up. This change brings the Ruby SDK in line with the Python SDK (`src/mcp/server/streamable_http.py`), which falls back to `DEFAULT_NEGOTIATED_VERSION` and then revalidates against the supported list. ## Behavior A new constant `MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION` holds `"2025-03-26"`. `validate_protocol_version_header` now substitutes this value when the `MCP-Protocol-Version` header is absent, then runs the same supported-version check. Because `"2025-03-26"` is currently in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, missing headers continue to pass through as before. Once `"2025-03-26"` is dropped from the supported list in a future change, missing headers will start returning 400 with a message naming the defaulted value. ## How Has This Been Tested? Existing test `POST request without MCP-Protocol-Version header succeeds` continues to pass (observable behavior unchanged today). Added a focused regression test that stubs the supported-version list to exclude `"2025-03-26"` and verifies that a header-less request is rejected with the defaulted value reported in the error message; this exercises the fallback branch directly. ## Breaking Changes None today. The fallback is future-proofing: when `"2025-03-26"` is eventually dropped, header-less requests will start returning 400. That is the intended spec-driven behavior. --- lib/mcp/configuration.rb | 1 + .../transports/streamable_http_transport.rb | 3 +- .../streamable_http_transport_test.rb | 44 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index e24dc73..70acb50 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -6,6 +6,7 @@ module MCP SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", ] + DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" attr_writer :exception_reporter, :around_request diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 39950f3..dfe385b 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -543,8 +543,7 @@ module MCP end def validate_protocol_version_header(request) - header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] - return if header_value.nil? + header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ") diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 20d094a..0f8b25c 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1778,6 +1778,50 @@ module MCP assert_equal 200, response[0] end + test "missing MCP-Protocol-Version header falls back to default for validation" do + MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.stubs(:include?).returns(false) + + request = Rack::Request.new( + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + ) + + response = @transport.send(:validate_protocol_version_header, request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION + end + + test "POST request with empty MCP-Protocol-Version header returns 400" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + test "POST request with array body and unsupported MCP-Protocol-Version returns 400" do init_request = create_rack_request( "POST", From 91ff179d788a4585d79de895e5b517eb029cd69e Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Sun, 7 Jun 2026 16:32:21 +0900 Subject: [PATCH 094/247] Add `size` field to `MCP::Resource` per MCP specification ## Motivation and Context The MCP specification defines an optional `size` field on the `Resource` type, added in spec revision 2025-06-18 and retained in 2025-11-25: https://modelcontextprotocol.io/specification/2025-11-25/server/resources > The size of the raw resource content, in bytes (i.e., before base64 > encoding or any tokenization), if known. This can be used by Hosts to > display file sizes and estimate context window usage. `MCP::Resource` was missing this field, so servers had no way to declare a resource's byte size. Without it, hosts cannot show file sizes or estimate context window usage before reading the resource contents. This aligns the Ruby SDK with the spec, matching the TypeScript SDK (modelcontextprotocol/typescript-sdk#1574). ## How Has This Been Tested? Added tests in `test/mcp/resource_test.rb`: - `#to_h` omits `size` when nil - `#to_h` includes `size` when present - `#to_h` includes `size` when zero (distinguishes an unset size from a 0-byte resource) The full unit suite and RuboCop pass locally. ## Breaking Changes None. `size:` is a new optional keyword argument defaulting to `nil`, and it is omitted from `#to_h` output when not set, so existing callers are unaffected. --- lib/mcp/resource.rb | 6 ++++-- test/mcp/resource_test.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 990156b..e4e55fc 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -5,15 +5,16 @@ require_relative "resource/embedded" module MCP class Resource - attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta - def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil) + def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil) @uri = uri @name = name @title = title @description = description @icons = icons @mime_type = mime_type + @size = size @meta = meta end @@ -25,6 +26,7 @@ module MCP description: description, icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + size: size, _meta: meta, }.compact end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index fb2254d..a3d6a1b 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -49,5 +49,23 @@ module MCP assert_equal meta, resource.to_h[:_meta] end + + test "#to_h omits size when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_size") + + refute resource.to_h.key?(:size) + end + + test "#to_h includes size when present" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_size", size: 12_345) + + assert_equal 12_345, resource.to_h[:size] + end + + test "#to_h includes size when zero" do + resource = Resource.new(uri: "file:///empty.txt", name: "empty_resource", size: 0) + + assert_equal 0, resource.to_h[:size] + end end end From 2a26ad2030ada924942190baf622e030127ab890 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 15:48:32 +0900 Subject: [PATCH 095/247] Pin RFC 8414 Default Well-Known Suffix per SEP-2351 ## Motivation and Context SEP-2351 (modelcontextprotocol/modelcontextprotocol#2351, merged for the 2026-07-28 spec release) makes explicit that MCP uses the default `oauth-authorization-server` well-known URI suffix from RFC 8414 Section 3.1 and defines no application-specific suffix. `MCP::Client::OAuth::Discovery.authorization_server_metadata_urls` already conforms: it probes the RFC 8414 default suffix first (path-inserted form for path issuers) and otherwise uses only the OpenID Connect Discovery `openid-configuration` suffix. Neither the Python SDK nor the TypeScript SDK needed a behavior change for this SEP either; their tracking issues (python-sdk#2799, typescript-sdk#2256) remain documentation-level. This change documents the SEP-2351 guarantee in the discovery helper and locks the behavior in with regression tests so a future refactor cannot silently introduce a non-default suffix. Resolves #384. ## How Has This Been Tested? New regression tests in `test/mcp/client/oauth/discovery_test.rb`: - the first probed candidate for a root issuer is exactly `/.well-known/oauth-authorization-server` - every generated candidate uses only the `oauth-authorization-server` or `openid-configuration` suffix - the RFC 8414 path-inserted `oauth-authorization-server` candidate comes first for issuers with a path component - an issuer URL with a trailing slash is treated as a root issuer ## Breaking Changes None. Documentation and regression tests only; no behavior change. --- lib/mcp/client/oauth/discovery.rb | 12 +++++++- test/mcp/client/oauth/discovery_test.rb | 37 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index 3360729..d7ab4a9 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -90,7 +90,17 @@ module MCP end # Returns the candidate Authorization Server metadata URLs to probe, in priority order. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery + # + # Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix + # registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own. + # The OAuth candidates below therefore use only that default suffix + # (plus the `openid-configuration` suffix from OpenID Connect Discovery), + # both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component + # and in the root form for issuers without one. + # + # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351 + # - https://www.rfc-editor.org/rfc/rfc8414#section-3.1 def authorization_server_metadata_urls(issuer_url) uri = URI.parse(issuer_url) path = uri.path == "/" ? "" : uri.path.to_s diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index 4dbd1c3..245e9b7 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -121,6 +121,43 @@ module MCP assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration") end + # Per SEP-2351, MCP explicitly uses the RFC 8414 default `oauth-authorization-server` well-known URI suffix + # and defines no application-specific suffix. The first probed candidate for a root issuer must be exactly + # that default suffix. + def test_authorization_server_metadata_urls_probe_rfc8414_default_suffix_first + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + + assert_equal("https://auth.example.com/.well-known/oauth-authorization-server", urls.first) + end + + def test_authorization_server_metadata_urls_use_only_registered_well_known_suffixes + root_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + path_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + (root_urls + path_urls).each do |url| + suffix = url[%r{/\.well-known/([^/]+)}, 1] + + assert_includes( + ["oauth-authorization-server", "openid-configuration"], suffix, "unexpected well-known suffix in #{url}" + ) + end + end + + def test_authorization_server_metadata_urls_put_path_inserted_oauth_candidate_first_for_path_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + assert_equal("https://auth.example.com/.well-known/oauth-authorization-server/tenant1", urls.first) + end + + def test_authorization_server_metadata_urls_treat_trailing_slash_issuer_as_root + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/") + + assert_equal( + ["https://auth.example.com/.well-known/oauth-authorization-server", "https://auth.example.com/.well-known/openid-configuration"], + urls, + ) + end + def test_canonicalize_url_normalizes_scheme_host_port_and_path assert_equal( "https://srv.example.com/mcp", From a290fe281a031e977142ca6ed5f069035243d9c8 Mon Sep 17 00:00:00 2001 From: baxter2 Date: Thu, 11 Jun 2026 17:50:59 +1000 Subject: [PATCH 096/247] Extract `MCP::Client` pagination loop into `fetch_all_pages` `#tools`, `#resources`, `#resource_templates`, and `#prompts` each repeated the same loop to follow `next_cursor` and collect every page, differing only in the `list_*` method called and the items field read from each page. Move that loop into a single private `fetch_all_pages` helper so the four methods are one-liners and the cursor-cycle guard lives in one place. Behavior is unchanged; the existing `MCP::Client` list tests cover it. --- lib/mcp/client.rb | 85 ++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 60 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 9a22bd6..23ba7dc 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -146,21 +146,7 @@ module MCP # end def tools # TODO: consider renaming to `list_all_tools`. - all_tools = [] - seen = Set.new - cursor = nil - - loop do - page = list_tools(cursor: cursor) - all_tools.concat(page.tools) - next_cursor = page.next_cursor - break if next_cursor.nil? || seen.include?(next_cursor) - - seen << next_cursor - cursor = next_cursor - end - - all_tools + fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools) end # Returns a single page of resources from the server. @@ -189,21 +175,7 @@ module MCP # @return [Array] An array of available resources. def resources # TODO: consider renaming to `list_all_resources`. - all_resources = [] - seen = Set.new - cursor = nil - - loop do - page = list_resources(cursor: cursor) - all_resources.concat(page.resources) - next_cursor = page.next_cursor - break if next_cursor.nil? || seen.include?(next_cursor) - - seen << next_cursor - cursor = next_cursor - end - - all_resources + fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources) end # Returns a single page of resource templates from the server. @@ -232,21 +204,7 @@ module MCP # @return [Array] An array of available resource templates. def resource_templates # TODO: consider renaming to `list_all_resource_templates`. - all_templates = [] - seen = Set.new - cursor = nil - - loop do - page = list_resource_templates(cursor: cursor) - all_templates.concat(page.resource_templates) - next_cursor = page.next_cursor - break if next_cursor.nil? || seen.include?(next_cursor) - - seen << next_cursor - cursor = next_cursor - end - - all_templates + fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates) end # Returns a single page of prompts from the server. @@ -275,21 +233,7 @@ module MCP # @return [Array] An array of available prompts. def prompts # TODO: consider renaming to `list_all_prompts`. - all_prompts = [] - seen = Set.new - cursor = nil - - loop do - page = list_prompts(cursor: cursor) - all_prompts.concat(page.prompts) - next_cursor = page.next_cursor - break if next_cursor.nil? || seen.include?(next_cursor) - - seen << next_cursor - cursor = next_cursor - end - - all_prompts + fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts) end # Calls a tool via the transport layer and returns the full response from the server. @@ -380,6 +324,27 @@ module MCP private + # Walks every page of a list endpoint, following `next_cursor`, and returns + # the page results. The `seen` set guards against a server that repeats or + # cycles cursors, so the loop always terminates. + def fetch_all_pages + pages = [] + seen = Set.new + cursor = nil + + loop do + page = yield(cursor) + pages << page + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + seen << next_cursor + cursor = next_cursor + end + + pages + end + def request(method:, params: nil) request_body = { jsonrpc: JsonRpcHandler::Version::V2_0, From a407cea086ddfc1785ca06d438eed79f83580ed3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 28 May 2026 13:06:32 +0900 Subject: [PATCH 097/247] Re-run OAuth flow on 403 insufficient_scope (step-up) ## Motivation and Context The MCP authorization specification (2025-11-25 scope-selection-strategy) and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication: the client already holds a valid access token, but the server returns a 403 with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."` to demand a higher-privileged scope for a specific operation. The client is expected to run a fresh authorization request with the escalated scope and retry. Until now the Ruby SDK only ran the OAuth flow on 401 responses. A 403 surfaced unchanged as a `RequestHandlerError` and the operation that needed the escalated scope failed permanently, even with a valid token in storage. This lined up with the `auth/scope-step-up` conformance scenario being listed in `conformance/expected_failures.yml`. The change adds: - `MCP::Client::HTTP#send_request` recognises a 403 carrying an `insufficient_scope` Bearer challenge as a step-up signal, re-runs a full authorization request with the scope from the challenge, and retries the original request. Retries are capped at one per `send_request` call so a server that repeatedly demands more scope cannot loop the transport. - `run_step_up_flow!` deliberately bypasses the refresh-token path that `run_oauth_flow!` uses on 401. Refreshing would re-issue the same scope set the existing token already has and the server already rejected. Only a fresh `authorization_code` grant with the escalated scope can succeed. - A plain 403 with no `insufficient_scope` challenge continues to surface as `RequestHandlerError` with `error_type: :forbidden`. - `conformance/client.rb` invokes a tool for the `auth/scope-step-up` scenario; the scenario's authorization middleware only requires the escalated scope on `tools/call`, so the previous `tools/list`-only client could never trigger the second authorization. - `auth/scope-step-up` is removed from `conformance/expected_failures.yml`. Resolves #383. ## How Has This Been Tested? New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path re-authorizes with the escalated scope from the challenge and retries the original request with the new bearer token; a plain 403 without the `insufficient_scope` challenge is surfaced as `error_type: :forbidden` with no OAuth side effects; repeated `insufficient_scope` responses retry at most once and then surface the 403; a provider with a saved `refresh_token` still goes through the full authorization request rather than exchanging the refresh token, because the existing scope set is precisely what was rejected. Conformance: `auth/scope-step-up` now passes 22/22 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope` Bearer challenge still raises the same `RequestHandlerError` as before. The new retry only fires when both the WWW-Authenticate header signals step-up and the transport was constructed with an OAuth provider. --- README.md | 3 + conformance/client.rb | 11 +- conformance/expected_failures.yml | 1 - lib/mcp/client/http.rb | 77 ++++- test/mcp/client/oauth/http_oauth_test.rb | 421 ++++++++++++++++++++++- 5 files changed, 494 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bdf0bd7..f62e456 100644 --- a/README.md +++ b/README.md @@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). +- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), + run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. + The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. - Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. diff --git a/conformance/client.rb b/conformance/client.rb index ecfd079..17a895d 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -116,7 +116,16 @@ when %r|\Aauth/| # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) # are observed by the conformance server during `connect` and the subsequent request below. # Listing tools forces a second authenticated MCP request so the bearer token usage check fires. - client.tools + tools = client.tools + + # `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`, + # so the client must actually invoke a tool to drive the second authorization request + # the scenario asserts on. + if scenario == "auth/scope-step-up" + tool = tools.find { |t| t.name == "test-tool" } || tools.first + abort("No tool exposed by conformance server for #{scenario}") unless tool + client.call_tool(tool: tool, arguments: {}) + end else abort("Unknown or unsupported scenario: #{scenario}") end diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index f19cd16..1098ce8 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -5,7 +5,6 @@ client: # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/scope-step-up - auth/2025-03-26-oauth-metadata-backcompat - auth/2025-03-26-oauth-endpoint-fallback - auth/client-credentials-jwt diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index f5bf93d..273a756 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -185,6 +185,7 @@ module MCP method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false + step_up_retried = false begin response = client.post("", request, session_headers) @@ -217,6 +218,19 @@ module MCP original_error: e, ) rescue Faraday::ForbiddenError => e + # OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in + # the Bearer challenge means the existing access token is valid + # but lacks scopes the server now requires for this operation. + # Re-run the full authorization flow with the escalated scope from + # the challenge and retry once. A plain 403 without the challenge is + # surfaced unchanged. + if @oauth && !step_up_retried && insufficient_scope_challenge?(e) + step_up_retried = true + run_step_up_flow!(forbidden_error: e) + + retry + end + raise RequestHandlerError.new( "You are forbidden to make #{method} requests", { method: method, params: params }, @@ -337,16 +351,63 @@ module MCP # # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling def run_oauth_flow!(unauthorized_error:) - response = unauthorized_error.response || {} - response_headers = response[:headers] || {} - www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"] - params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate) - + params = parse_www_authenticate_from_error(unauthorized_error) flow = MCP::Client::OAuth::Flow.new(provider: @oauth) - if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"]) - return - end + return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"]) + run_full_authorization_flow!(flow: flow, params: params) + end + + # Drives a full Authorization Code + PKCE flow without first attempting + # to refresh the access token. Used for the MCP scope-selection-strategy + # step-up path: the provider already holds a valid access token, + # but the server returned a 403 with + # `WWW-Authenticate: ... error="insufficient_scope", scope="..."` + # per RFC 6750 Section 3.1. Refreshing the existing token would re-issue + # the same scope set the server already rejected, so the SDK must run + # a fresh authorization request. The request asks for the union of + # the currently granted scope and the newly demanded scope; otherwise + # the caller would lose previously held scopes and trigger another step-up + # on the next operation that needs them. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy + def run_step_up_flow!(forbidden_error:) + params = parse_www_authenticate_from_error(forbidden_error) + flow = MCP::Client::OAuth::Flow.new(provider: @oauth) + params = params.merge("scope" => escalated_step_up_scope(params["scope"])) + + run_full_authorization_flow!(flow: flow, params: params) + end + + # Returns the space-separated union of the currently granted scope (read + # from the stored token response per RFC 6749 Section 5.1) and the scope + # demanded by the step-up challenge. Duplicates are collapsed; order + # follows first appearance so existing scopes precede the newly added + # ones. Returns `nil` when neither side carries a scope so + # `build_authorization_url` omits the `scope` parameter entirely. + def escalated_step_up_scope(challenge_scope) + tokens = @oauth.tokens + granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil + scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq + + scopes.empty? ? nil : scopes.join(" ") + end + + # True when the response on `forbidden_error` carries a Bearer challenge + # with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP + # scope-selection-strategy section. A 403 without that signal is not a + # step-up challenge and must not trigger re-authorization. + def insufficient_scope_challenge?(forbidden_error) + parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope" + end + + def parse_www_authenticate_from_error(error) + response = error.response || {} + response_headers = response[:headers] || {} + header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"] + MCP::Client::OAuth::Discovery.parse_www_authenticate(header) + end + + def run_full_authorization_flow!(flow:, params:) # Use the URL snapshotted at `initialize` time so a post-construction # mutation of `@url` cannot redirect PRM/AS discovery and the authorize # URL to an attacker-controlled host. diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 9aaf572..c22ad86 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -22,15 +22,6 @@ module MCP WebMock.reset! end - def build_provider - Provider.new( - client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) {}, - callback_handler: -> { ["code", "state"] }, - ) - end - def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token # First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200. stub_request(:post, @mcp_url) @@ -110,6 +101,325 @@ module MCP assert_equal("test-token-after-flow", provider.access_token) end + def test_send_request_runs_step_up_flow_on_403_insufficient_scope + # First call with the initial token: 403 carrying an `insufficient_scope` + # Bearer challenge with the escalated scope. The transport must re-run + # a full authorization (NOT a refresh) with the escalated scope and + # retry the original request. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer escalated-token" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: { name: "x" } }, + ) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("escalated-token", provider.access_token) + + # The fresh authorization-code grant ran (refresh path was bypassed). + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" + end + + # And the authorization request the SDK handed to the redirect + # handler carried the escalated scope from the challenge. + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + assert_equal("mcp:basic mcp:write", authorization_query["scope"]) + end + + def test_send_request_step_up_unions_existing_scope_with_challenge_scope + # MCP step-up: the re-authorization request must ask for the union + # of the previously granted scope and the newly demanded scope. + # Otherwise the new token would lack permissions the caller had + # before, triggering another step-up the next time they are used. + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + # The provider already holds a token granted for `mcp:read`. + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + requested = authorization_query["scope"].split + assert_includes(requested, "mcp:read", "must preserve previously granted scope") + assert_includes(requested, "mcp:write", "must add the newly demanded scope") + assert_equal(requested.uniq.length, requested.length, "must not duplicate scopes") + end + + def test_send_request_does_not_retry_plain_403 + # A 403 without an `insufficient_scope` Bearer challenge is a hard + # forbidden, not a step-up signal. Surface it as a `RequestHandlerError` + # rather than tripping the OAuth flow. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, body: "", + ) + + provider = build_step_up_provider + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + end + assert_equal(:forbidden, error.error_type) + assert_not_requested(:get, "#{@auth_base}/.well-known/oauth-authorization-server") + end + + def test_send_request_does_not_loop_on_repeated_insufficient_scope + # The server keeps demanding more scope after every authorization. + # The transport retries the step-up flow at most once per + # `send_request`, then surfaces the 403 to the caller. + stub_request(:post, @mcp_url).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:everything", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + end + assert_equal(:forbidden, error.error_type) + end + + def test_send_request_step_up_bypasses_refresh_token + # The provider has a refresh_token, but step-up MUST run a fresh + # authorization request with the escalated scope, not exchange the + # refresh_token. Refreshing would mint a new access token with the + # same scopes as the current one, which already failed. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer escalated-token" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + provider.save_tokens( + "access_token" => "initial-token", + "refresh_token" => "saved-rt", + "token_type" => "Bearer", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # The token endpoint was only called for the fresh + # `authorization_code` grant, never for `refresh_token`. + assert_not_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token" + end + end + + def test_send_request_step_up_appends_offline_access_when_advertised + # Integration of step-up with the SEP-2207 offline_access logic: + # the re-authorization request unions the existing and challenge scopes, + # then -- because the AS advertises `offline_access` and the client + # declared the `refresh_token` grant -- `offline_access` is appended + # so the escalated token can itself be refreshed later. + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server( + extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write", "offline_access"] }, + ) + provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"]) + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split + assert_includes(requested, "mcp:read", "must preserve previously granted scope") + assert_includes(requested, "mcp:write", "must add the challenged scope") + assert_includes(requested, "offline_access", "must append offline_access when advertised") + end + + def test_send_request_step_up_strips_offline_access_when_not_advertised + # Mirror of the previous test: if the AS does NOT advertise + # `offline_access`, the step-up request must not carry it even when + # the existing token's scope happened to include it + # (SEP-2207 MUST-NOT, enforced by `normalize_offline_access_scope`). + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + # AS advertises only resource scopes, no offline_access. + stub_step_up_authorization_server( + extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write"] }, + ) + provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"]) + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read offline_access", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split + assert_includes(requested, "mcp:read") + assert_includes(requested, "mcp:write") + refute_includes(requested, "offline_access", "must strip offline_access when the AS does not advertise it") + end + + def test_send_request_step_up_uses_cimd_client_id + # Integration of step-up with Client ID Metadata Documents: + # when the provider is CIMD-configured and the AS advertises CIMD support, + # the step-up re-authorization uses the CIMD URL as `client_id` and skips + # Dynamic Client Registration, just like the initial flow. + cimd_url = "https://app.example.com/client-metadata.json" + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server( + extra_as_metadata: { client_id_metadata_document_supported: true }, + ) + provider = build_step_up_provider(client_id_metadata_document_url: cimd_url) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # DCR was skipped and the CIMD URL was used as the client_id on + # the escalated authorization request. + assert_not_requested(:post, "#{@auth_base}/register") + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + assert_equal(cimd_url, authorization_query["client_id"]) + end + def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set # A bearer token sent to `http://attacker.example.com/mcp` would leak # over the wire. When OAuth is on, the transport URL must clear @@ -1080,6 +1390,99 @@ module MCP # proving the retry guard prevents an infinite re-authorization loop. assert_requested(:post, "#{@auth_base}/token", times: 1) end + + private + + # Stubs PRM, AS metadata, /register, and /token so a full authorization + # flow runs. The /token stub echoes `requested_scope` so callers can + # assert the escalated scope was sent. + # `extra_as_metadata` lets a test advertise additional AS capabilities + # (e.g. `scopes_supported` for the offline_access interaction, or + # `client_id_metadata_document_supported` for the CIMD interaction) + # without duplicating the whole stub. + def stub_step_up_authorization_server(extra_as_metadata: {}) + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate({ + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }.merge(extra_as_metadata)), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return do |request| + form = URI.decode_www_form(request.body).to_h + + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "escalated-token", + token_type: "Bearer", + expires_in: 3600, + requested_scope: form["scope"], + grant_type_received: form["grant_type"], + ), + } + end + end + + def build_step_up_provider(grant_types: ["authorization_code"], client_id_metadata_document_url: nil) + state_holder = {} + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: grant_types, + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: client_id_metadata_document_url, + ) + provider.save_tokens("access_token" => "initial-token", "token_type" => "Bearer") + + # `captured_authorization_url` is a closure variable that the redirect handler writes at flow time; + # expose its current value via a method on the provider so tests can assert on what was sent to /authorize. + provider.define_singleton_method(:last_authorization_url) { captured_authorization_url } + provider + end + + def build_provider + Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + end end end end From b1818f25c83041291995b8f87c60b97871acb167 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:16:45 +0900 Subject: [PATCH 098/247] Support W3C Trace Context Propagation via `_meta` per SEP-414 ## Motivation and Context SEP-414 (modelcontextprotocol/modelcontextprotocol#414, merged for the 2026-07-28 spec release) documents OpenTelemetry trace context propagation conventions for MCP: the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` are reserved (an explicit exception to the reverse-DNS prefix rule) so trace context can flow between MCP clients and servers per the W3C Trace Context and Baggage specifications. This follows the TypeScript SDK's approach (typescript-sdk#2270): guarantee `_meta` passthrough, export constants for the reserved key names, and lock the behavior in with regression tests and documentation, without adding an OpenTelemetry dependency (the Python SDK's python-sdk#2381 makes `opentelemetry-api` a hard dependency instead, which conflicts with this SDK's keep-dependencies-minimal policy): - New `MCP::TraceContext` module exposing `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`. - The server side already passes incoming request `_meta` to tool and prompt handlers untouched via `server_context[:_meta]`; new regression tests pin that guarantee for the three reserved keys. - Every `MCP::Client` request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) gains a `meta:` keyword so Ruby clients can inject trace context (or any other `_meta` entries) into outgoing requests; per SEP-414, trace context should flow on every request, not only tool calls. The caller's hash is never mutated. On `call_tool`, `progress_token` takes precedence over a `progressToken` entry in `meta`, and an empty merge result omits `_meta` entirely, preserving the existing wire format. - README documents the convention and how to bridge the values to a tracing system such as the `opentelemetry-ruby` gems. Scope is intentionally client-to-server: server-initiated requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) do not accept `meta:` yet; propagating trace context on those is a separate follow-up. Resolves #374. ## How Has This Been Tested? - New `test/mcp/trace_context_test.rb` pins the exact reserved key names and the frozen `META_KEYS` list. - New tests in `test/mcp/server_test.rb` assert that `tools/call` and `prompts/get` deliver `traceparent`, `tracestate`, and `baggage` (alongside `progressToken`) to handlers through `server_context[:_meta]` unchanged. - New tests in `test/mcp/client_test.rb` cover the `meta:` keyword: the trace keys are sent in `_meta` on `call_tool` and `read_resource`, a table-driven test exercises the remaining request methods (`tools/list`, `resources/list`, `resources/templates/list`, `prompts/list`, `prompts/get`, `completion/complete`, `ping`), `progress_token` overrides a `progressToken` entry in `meta` without duplicating the key, the caller's `meta` hash is not mutated, and requests without `meta:` serialize exactly as before (wire-format regressions for both the empty `meta` hash and the no-params list request). ## Breaking Changes None. `MCP::TraceContext` and the `meta:` keyword are purely additive, and requests without `meta:` serialize exactly as before. --- README.md | 30 ++++++++ lib/mcp.rb | 1 + lib/mcp/client.rb | 65 ++++++++++++----- lib/mcp/trace_context.rb | 23 ++++++ test/mcp/client_test.rb | 129 +++++++++++++++++++++++++++++++++ test/mcp/server_test.rb | 65 +++++++++++++++++ test/mcp/trace_context_test.rb | 20 +++++ 7 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 lib/mcp/trace_context.rb create mode 100644 test/mcp/trace_context_test.rb diff --git a/README.md b/README.md index bdf0bd7..8242ae7 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,36 @@ end } ``` +**Distributed Tracing (W3C Trace Context):** + +Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating +[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through +incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`, +`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values +to your tracing system yourself: + +```ruby +class TracedTool < MCP::Tool + def self.call(message:, server_context:) + traceparent = server_context.dig(:_meta, :traceparent) + # Hand traceparent/tracestate/baggage to your tracing library + # (e.g. the opentelemetry-ruby gems) to continue the caller's trace. + + MCP::Tool::Response.new([{ type: "text", text: "ok" }]) + end +end +``` + +On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) +accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request: + +```ruby +meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + +client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta) +client.read_resource(uri: "file:///report.txt", meta: meta) +``` + #### Configuration Block Data ##### Exception Reporter diff --git a/lib/mcp.rb b/lib/mcp.rb index 32b7eed..867eb02 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -19,6 +19,7 @@ module MCP autoload :Server, "mcp/server" autoload :ServerSession, "mcp/server_session" autoload :Tool, "mcp/tool" + autoload :TraceContext, "mcp/trace_context" class << self def configure diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 23ba7dc..ea71d01 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -103,6 +103,8 @@ module MCP # Returns a single page of tools from the server. # # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [MCP::Client::ListToolsResult] Result with `tools` (Array) # and `next_cursor` (String or nil). # @@ -114,9 +116,9 @@ module MCP # cursor = page.next_cursor # break unless cursor # end - def list_tools(cursor: nil) + def list_tools(cursor: nil, meta: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "tools/list", params: params) + response = request(method: "tools/list", params: params, meta: meta) result = response["result"] || {} tools = (result["tools"] || []).map do |tool| @@ -152,11 +154,13 @@ module MCP # Returns a single page of resources from the server. # # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array) # and `next_cursor` (String or nil). - def list_resources(cursor: nil) + def list_resources(cursor: nil, meta: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "resources/list", params: params) + response = request(method: "resources/list", params: params, meta: meta) result = response["result"] || {} ListResourcesResult.new( @@ -181,11 +185,13 @@ module MCP # Returns a single page of resource templates from the server. # # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates` # (Array) and `next_cursor` (String or nil). - def list_resource_templates(cursor: nil) + def list_resource_templates(cursor: nil, meta: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "resources/templates/list", params: params) + response = request(method: "resources/templates/list", params: params, meta: meta) result = response["result"] || {} ListResourceTemplatesResult.new( @@ -210,11 +216,13 @@ module MCP # Returns a single page of prompts from the server. # # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array) # and `next_cursor` (String or nil). - def list_prompts(cursor: nil) + def list_prompts(cursor: nil, meta: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "prompts/list", params: params) + response = request(method: "prompts/list", params: params, meta: meta) result = response["result"] || {} ListPromptsResult.new( @@ -242,6 +250,10 @@ module MCP # @param tool [MCP::Client::Tool] The tool to be called. # @param arguments [Object, nil] The arguments to pass to the tool. # @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. the W3C Trace Context keys reserved by SEP-414 + # (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`). + # `progress_token` takes precedence over a `progressToken` entry in `meta`. # @return [Hash] The full JSON-RPC response from the transport. # # @example Call by name @@ -256,14 +268,17 @@ module MCP # @note # The exact requirements for `arguments` are determined by the transport layer in use. # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil) + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name params = { name: tool_name, arguments: arguments } + meta_entries = meta ? meta.dup : {} if progress_token - params[:_meta] = { progressToken: progress_token } + meta_entries.delete("progressToken") + meta_entries[:progressToken] = progress_token end + params[:_meta] = meta_entries unless meta_entries.empty? request(method: "tools/call", params: params) end @@ -271,9 +286,11 @@ module MCP # Reads a resource from the server by URI and returns the contents. # # @param uri [String] The URI of the resource to read. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:) - response = request(method: "resources/read", params: { uri: uri }) + def read_resource(uri:, meta: nil) + response = request(method: "resources/read", params: { uri: uri }, meta: meta) response.dig("result", "contents") || [] end @@ -281,9 +298,11 @@ module MCP # Gets a prompt from the server by name and returns its details. # # @param name [String] The name of the prompt to get. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [Hash] A hash containing the prompt details. - def get_prompt(name:) - response = request(method: "prompts/get", params: { name: name }) + def get_prompt(name:, meta: nil) + response = request(method: "prompts/get", params: { name: name }, meta: meta) response.fetch("result", {}) end @@ -294,12 +313,14 @@ module MCP # or `{ type: "ref/resource", uri: "file:///{path}" }`. # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`. # @param context [Hash, nil] Optional context with previously resolved arguments. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`. - def complete(ref:, argument:, context: nil) + def complete(ref:, argument:, context: nil, meta: nil) params = { ref: ref, argument: argument } params[:context] = context if context - response = request(method: "completion/complete", params: params) + response = request(method: "completion/complete", params: params, meta: meta) response.dig("result", "completion") || { "values" => [], "hasMore" => false } end @@ -315,8 +336,8 @@ module MCP # client.ping # => {} # # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping - def ping - result = request(method: Methods::PING)["result"] + def ping(meta: nil) + result = request(method: Methods::PING, meta: meta)["result"] raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) result @@ -345,7 +366,13 @@ module MCP pages end - def request(method:, params: nil) + # Merges caller-supplied `meta` entries into the request params as `_meta`, + # without mutating the caller's hashes. Per SEP-414, `_meta` carries + # request-specific metadata such as W3C trace context (`traceparent`, + # `tracestate`, `baggage`); see {MCP::TraceContext}. + def request(method:, params: nil, meta: nil) + params = (params || {}).merge(_meta: meta) if meta && !meta.empty? + request_body = { jsonrpc: JsonRpcHandler::Version::V2_0, id: request_id, diff --git a/lib/mcp/trace_context.rb b/lib/mcp/trace_context.rb new file mode 100644 index 0000000..0d69ec5 --- /dev/null +++ b/lib/mcp/trace_context.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MCP + # Reserved `_meta` keys for W3C Trace Context propagation, per SEP-414. + # + # The MCP spec reserves the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` + # (an explicit exception to the reverse-DNS prefix rule for `_meta` keys) so that clients and + # servers can propagate distributed-tracing context across MCP requests. + # The SDK guarantees these keys pass through incoming request `_meta` untouched; tool, prompt, + # and resource handlers can read them from `server_context[:_meta]` and bridge them to a tracing + # system such as the `opentelemetry-ruby` gems. The SDK itself does not depend on OpenTelemetry. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414 + # - https://www.w3.org/TR/trace-context/ + # - https://www.w3.org/TR/baggage/ + module TraceContext + TRACEPARENT_META_KEY = "traceparent" + TRACESTATE_META_KEY = "tracestate" + BAGGAGE_META_KEY = "baggage" + + META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY].freeze + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index d5cae9a..3c69081 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -430,6 +430,135 @@ module MCP client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token) end + def test_call_tool_sends_trace_context_meta_entries + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { + MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + MCP::TraceContext::TRACESTATE_META_KEY => "vendor=value", + MCP::TraceContext::BAGGAGE_META_KEY => "userId=alice", + } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + sent_meta = args.dig(:request, :params, :_meta) + sent_meta["traceparent"] == meta["traceparent"] && + sent_meta["tracestate"] == meta["tracestate"] && + sent_meta["baggage"] == meta["baggage"] + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, meta: meta) + end + + def test_call_tool_merges_meta_with_progress_token_taking_precedence + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { "traceparent" => "00-trace-span-01", "progressToken" => "from-meta" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + sent_meta = args.dig(:request, :params, :_meta) + sent_meta["traceparent"] == "00-trace-span-01" && + sent_meta[:progressToken] == "explicit-token" && + !sent_meta.key?("progressToken") + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, progress_token: "explicit-token", meta: meta) + end + + def test_call_tool_omits_meta_when_empty_meta_hash_given + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params).key?(:_meta) == false + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, meta: {}) + end + + def test_call_tool_does_not_mutate_caller_meta + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, progress_token: "t", meta: meta) + + assert_equal({ "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }, meta) + end + + def test_read_resource_sends_meta_when_provided + transport = mock + traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" + mock_response = { "result" => { "contents" => [] } } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "resources/read" && + args.dig(:request, :params, :uri) == "file:///foo" && + args.dig(:request, :params, :_meta, :traceparent) == traceparent + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.read_resource(uri: "file:///foo", meta: { traceparent: traceparent }) + end + + def test_request_methods_send_meta_when_provided + # Per SEP-414, trace context should flow on every request, so the `meta:` keyword + # is available on all client request methods. + traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" + meta = { traceparent: traceparent } + + [ + ["tools/list", { "tools" => [] }, ->(client) { client.list_tools(meta: meta) }], + ["resources/list", { "resources" => [] }, ->(client) { client.list_resources(meta: meta) }], + ["resources/templates/list", { "resourceTemplates" => [] }, ->(client) { client.list_resource_templates(meta: meta) }], + ["prompts/list", { "prompts" => [] }, ->(client) { client.list_prompts(meta: meta) }], + ["prompts/get", {}, ->(client) { client.get_prompt(name: "p", meta: meta) }], + [ + "completion/complete", + { "completion" => { "values" => [] } }, + ->(client) { + client.complete(ref: { type: "ref/prompt", name: "p" }, argument: { name: "a", value: "v" }, meta: meta) + }, + ], + ["ping", {}, ->(client) { client.ping(meta: meta) }], + ].each do |method, result, invoke| + transport = mock + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == method && + args.dig(:request, :params, :_meta, :traceparent) == traceparent + end.returns({ "result" => result }).once + + invoke.call(Client.new(transport: transport)) + end + end + + def test_request_methods_omit_meta_when_not_provided + # Wire-format regression: without `meta:`, list requests keep sending no `params` at all. + transport = mock + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" && !args[:request].key?(:params) + end.returns({ "result" => { "tools" => [] } }).once + + Client.new(transport: transport).list_tools + end + def test_call_tool_omits_meta_when_no_progress_token transport = mock tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 890a1ac..35a72a7 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2659,6 +2659,71 @@ module MCP assert_equal "from_accessor", received_context[:custom] end + test "#handle tools/call passes W3C trace context _meta keys through to the handler" do + # Per SEP-414, `traceparent`, `tracestate`, and `baggage` are reserved + # un-prefixed `_meta` keys and must never be stripped by the SDK. + server = Server.new(name: "trace_test", tools: []) + received_context = nil + server.define_tool(name: "trace_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "trace_tool", + arguments: {}, + _meta: { + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=value", + baggage: "userId=alice", + progressToken: "token-1", + }, + }, + }) + + meta = received_context[:_meta] + assert_equal "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", meta[:traceparent] + assert_equal "vendor=value", meta[:tracestate] + assert_equal "userId=alice", meta[:baggage] + assert_equal "token-1", meta[:progressToken] + end + + test "#handle prompts/get passes W3C trace context _meta keys through to the handler" do + server = Server.new(name: "trace_test", prompts: []) + received_context = nil + server.define_prompt(name: "trace_prompt", arguments: []) do |_args, server_context:| + received_context = server_context + Prompt::Result.new( + description: "a prompt description", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))], + ) + end + + server.handle({ + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "trace_prompt", + arguments: {}, + _meta: { + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=value", + baggage: "userId=alice", + }, + }, + }) + + meta = received_context[:_meta] + MCP::TraceContext::META_KEYS.each do |key| + assert meta.key?(key.to_sym), "expected _meta to retain #{key}" + end + end + test "#handle tools/list returns paginated results when page_size is set" do tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") diff --git a/test/mcp/trace_context_test.rb b/test/mcp/trace_context_test.rb new file mode 100644 index 0000000..cfe59d8 --- /dev/null +++ b/test/mcp/trace_context_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class TraceContextTest < ActiveSupport::TestCase + test "exposes the W3C Trace Context reserved _meta key names" do + # The exact un-prefixed names are reserved by SEP-414; renaming any of them + # would break interoperability with other SDKs. + assert_equal "traceparent", TraceContext::TRACEPARENT_META_KEY + assert_equal "tracestate", TraceContext::TRACESTATE_META_KEY + assert_equal "baggage", TraceContext::BAGGAGE_META_KEY + end + + test "META_KEYS lists all reserved keys and is frozen" do + assert_equal ["traceparent", "tracestate", "baggage"], TraceContext::META_KEYS + assert_predicate TraceContext::META_KEYS, :frozen? + end + end +end From 2289db84f8577a261cfab084dd99af87084bacf6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 13 Jun 2026 00:12:19 +0900 Subject: [PATCH 099/247] Support OAuth `client_credentials` grant in OAuth client ## Motivation and Context The MCP authorization specification and SEP-1046 allow confidential clients to obtain tokens through the OAuth 2.1 `client_credentials` grant: a machine-to-machine flow with no user, no browser redirect, and no authorization code. The Ruby SDK previously supported only the interactive authorization-code flow, so it could not authenticate against a server that requires `client_credentials`. This makes the `auth/client-credentials-basic` conformance scenario pass and is removed from the expected failures list. Following the TypeScript SDK (`ClientCredentialsProvider`) and the Python SDK (`ClientCredentialsOAuthProvider`), the grant gets its own provider class rather than overloading the authorization-code provider. The change adds: - `MCP::Client::OAuth::ClientCredentialsProvider`, a dedicated provider for the grant. `client_id` and `client_secret` are mandatory (the grant is for confidential clients), `token_endpoint_auth_method` is `client_secret_basic` (default) or `client_secret_post` -- `none` is rejected because an unauthenticated `client_credentials` request is meaningless. It has no redirect arguments, so a credentials-only client never supplies the redirect URI / handlers that grant has no use for. - `MCP::Client::OAuth::StorageBackedProvider`, an internal module that shares the token / client-information persistence between the two provider classes so `Flow` can treat any provider uniformly. - `Provider#authorization_flow` (`:authorization_code`) and `ClientCredentialsProvider#authorization_flow` (`:client_credentials`). `Flow#run!` dispatches on this rather than inspecting `client_metadata[:grant_types]`, which is protocol metadata for the authorization server, not an SDK control signal. - `run_client_credentials!` shares the same PRM / AS metadata discovery and Communication Security checks as the interactive flow, then exchanges `grant_type=client_credentials` at the token endpoint via the existing `post_to_token_endpoint` (which already applies `client_secret_basic`). There is no PKCE, redirect, or `offline_access` augmentation, because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). - `conformance/client.rb` builds a `ClientCredentialsProvider` for the `auth/client-credentials-*` scenarios, reusing the harness-injected `client_id` / `client_secret`. ## How Has This Been Tested? New `ClientCredentialsProvider` tests cover: credentials are stored as client information; `authorization_flow` is `:client_credentials`; `client_secret_post` is accepted; missing `client_id` / `client_secret` and `token_endpoint_auth_method: "none"` are rejected; and the token helpers delegate to storage. New `Flow` tests cover: a `client_credentials` provider exchanges `grant_type=client_credentials` with HTTP Basic auth, saves the token, and never contacts the authorization or registration endpoint; the requested scope is taken from PRM `scopes_supported`; and a standard `Provider` runs the authorization-code grant regardless of what its `client_metadata[:grant_types]` lists. A `Provider` test asserts `authorization_flow` is `:authorization_code`. Conformance: `auth/client-credentials-basic` now passes 7/7 with no failures. ## Breaking Changes None. `ClientCredentialsProvider` is a new class; the existing `Provider` keeps its required `redirect_uri` / `redirect_handler` / `callback_handler` arguments and its behaviour is unchanged. Existing interactive clients are unaffected. --- README.md | 23 ++++ conformance/client.rb | 45 ++++++-- conformance/expected_failures.yml | 1 - lib/mcp/client/oauth.rb | 4 +- .../oauth/client_credentials_provider.rb | 89 +++++++++++++++ lib/mcp/client/oauth/flow.rb | 66 +++++++++-- lib/mcp/client/oauth/provider.rb | 37 ++----- .../client/oauth/storage_backed_provider.rb | 43 ++++++++ .../oauth/client_credentials_provider_test.rb | 74 +++++++++++++ test/mcp/client/oauth/flow_test.rb | 104 ++++++++++++++++++ test/mcp/client/oauth/provider_test.rb | 6 + 11 files changed, 445 insertions(+), 47 deletions(-) create mode 100644 lib/mcp/client/oauth/client_credentials_provider.rb create mode 100644 lib/mcp/client/oauth/storage_backed_provider.rb create mode 100644 test/mcp/client/oauth/client_credentials_provider_test.rb diff --git a/README.md b/README.md index bdf0bd7..dfa9c14 100644 --- a/README.md +++ b/README.md @@ -2014,6 +2014,29 @@ provider = MCP::Client::OAuth::Provider.new( ) ``` +##### Client Credentials Grant + +For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. +The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post" + # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory. +- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + ##### Communication Security When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, diff --git a/conformance/client.rb b/conformance/client.rb index ecfd079..2f49575 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -51,6 +51,30 @@ rescue JSON::ParserError {} end +# Saves the pre-registered `client_id` / `client_secret` the harness injects +# via context (used by pre-registration and client_credentials scenarios). +def storage_for(context) + storage = MCP::Client::OAuth::InMemoryStorage.new + if context["client_id"] + storage.save_client_information( + "client_id" => context["client_id"], + "client_secret" => context["client_secret"], + "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end + storage +end + +# Builds a `client_credentials`-only provider (machine-to-machine, no redirect). +# The pre-registered credentials are injected by the harness via context. +def build_client_credentials_provider(context) + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", + ) +end + # Builds an OAuth provider that drives the authorization code + PKCE + DCR flow # non-interactively against the conformance test's auth server. The conformance # `/authorize` endpoint redirects synchronously to `redirect_uri` with @@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:) [query["code"], query["state"]] end - storage = MCP::Client::OAuth::InMemoryStorage.new - if context["client_id"] - storage.save_client_information( - "client_id" => context["client_id"], - "client_secret" => context["client_secret"], - "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", - ) - end - MCP::Client::OAuth::Provider.new( client_metadata: { client_name: "ruby-sdk-conformance-client", @@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:) redirect_uri: redirect_uri, redirect_handler: redirect_handler, callback_handler: callback_handler, - storage: storage, + storage: storage_for(context), client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil), ) end -oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil +def build_provider_for(scenario, context) + if scenario.start_with?("auth/client-credentials") + build_client_credentials_provider(context) + else + build_oauth_provider(context, scenario: scenario) + end +end + +oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index f19cd16..35b36a9 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -9,5 +9,4 @@ client: - auth/2025-03-26-oauth-metadata-backcompat - auth/2025-03-26-oauth-endpoint-fallback - auth/client-credentials-jwt - - auth/client-credentials-basic - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index 5115118..fc33c17 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -4,13 +4,15 @@ require_relative "oauth/discovery" require_relative "oauth/flow" require_relative "oauth/in_memory_storage" require_relative "oauth/pkce" +require_relative "oauth/storage_backed_provider" require_relative "oauth/provider" +require_relative "oauth/client_credentials_provider" module MCP class Client # OAuth client support for the MCP Authorization spec (PRM discovery, # Authorization Server metadata discovery, Dynamic Client Registration, - # OAuth 2.1 Authorization Code + PKCE). + # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant). # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization module OAuth end diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb new file mode 100644 index 0000000..fa01d5c --- /dev/null +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # OAuth client configuration for the OAuth 2.1 `client_credentials` grant + # (machine-to-machine, no user and no browser redirect). Handed to + # `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`. + # The interactive Authorization Code flow lives in `Provider`; + # this class exists so a credentials-only client never has to supply + # the redirect arguments that grant has no use for, mirroring the dedicated + # `ClientCredentialsProvider` in the TypeScript SDK and + # `ClientCredentialsOAuthProvider` in the Python SDK. + # + # Required keyword arguments: + # + # - `client_id` - String identifying the pre-registered confidential client. + # - `client_secret` - String shared secret. The `client_credentials` grant + # is for confidential clients, so a credential is mandatory. + # + # Optional keyword arguments: + # + # - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or + # `"client_secret_post"`. `"none"` is rejected: an unauthenticated + # `client_credentials` request is meaningless. + # - `scope` - String of space-separated scopes to request when the server's + # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, + # `client_information`, and `save_client_information(info)`. Defaults to + # an `InMemoryStorage`. The `client_id` / `client_secret` are written + # into it so the token exchange reads them through the same path as + # a pre-registered authorization-code client. + class ClientCredentialsProvider + include StorageBackedProvider + + # Raised when the credentials required for the `client_credentials` grant are + # missing or the requested client authentication method cannot carry them. + class InvalidCredentialsError < ArgumentError; end + + SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze + + attr_reader :scope, :storage + + def initialize( + client_id:, + client_secret:, + token_endpoint_auth_method: "client_secret_basic", + scope: nil, + storage: nil + ) + if blank?(client_id) + raise InvalidCredentialsError, "client_id is required for the client_credentials grant." + end + + unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method) + raise InvalidCredentialsError, + "token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \ + "client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \ + "client_credentials request is not allowed." + end + + if blank?(client_secret) + raise InvalidCredentialsError, + "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + end + + @scope = scope + @storage = storage || InMemoryStorage.new + @storage.save_client_information( + "client_id" => client_id, + "client_secret" => client_secret, + "token_endpoint_auth_method" => token_endpoint_auth_method, + ) + end + + # See `Provider#authorization_flow`. + def authorization_flow + :client_credentials + end + + private + + def blank?(value) + value.nil? || (value.is_a?(String) && value.strip.empty?) + end + end + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index aed5464..ed76ee5 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -54,6 +54,11 @@ module MCP as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server) ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) ensure_secure_endpoints!(as_metadata) + + if provider_authorization_flow == :client_credentials + return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) + end + ensure_pkce_supported!(as_metadata) client_info = ensure_client_registered(as_metadata: as_metadata) @@ -92,14 +97,46 @@ module MCP :authorized end - # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 - # Section 6). Re-discovers PRM and AS metadata so we always pick up a moved - # token endpoint, and re-runs the audience / issuer / security checks - # before talking to it. + # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists + # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is + # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, + # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). + # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + def run_client_credentials!(as_metadata:, prm:, resource:, scope:) + client_info = client_credentials_client_info + + form = { "grant_type" => "client_credentials" } + effective_scope = resolve_scope(scope: scope, prm: prm) + form["scope"] = effective_scope if effective_scope + form["resource"] = resource if resource + + tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + @provider.save_tokens(tokens) + :authorized + end + + # Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored + # `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code + # flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`). + # The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback + # to dynamic registration. + def client_credentials_client_info + info = @provider.client_information + unless info.is_a?(Hash) && client_info_required_value(info, "client_id") + raise AuthorizationError, + "Cannot run the client_credentials grant: the provider has no stored `client_id`." + end + + info + end + + # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6). + # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security + # checks before talking to it. # - # Returns `:refreshed` on success. Raises `AuthorizationError` when - # the provider has no refresh token, no client information, or when - # the token endpoint refuses the refresh request. + # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, + # or when the token endpoint refuses the refresh request. # https://www.rfc-editor.org/rfc/rfc6749#section-6 def refresh!(server_url:, resource_metadata_url: nil) refresh_token = read_token("refresh_token") @@ -110,8 +147,7 @@ module MCP # A CIMD-configured provider stores no `client_information` on purpose # (the CIMD URL is re-resolved against the live AS metadata on every flow). - # Allow refresh to proceed in that case so the `refresh_token` obtained via - # the CIMD flow remains usable. + # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable. have_cimd_url = !@provider.client_id_metadata_document_url.nil? unless have_stored_client_info || have_cimd_url @@ -484,6 +520,18 @@ module MCP Array(grant_types).include?("refresh_token") end + # The OAuth flow the provider drives. Dispatching on the provider's + # declared flow keeps `Flow` from second-guessing intent by parsing + # `client_metadata[:grant_types]` (which is protocol metadata for the + # authorization server, not an SDK control signal). A provider that + # predates this method is treated as the interactive authorization-code + # flow it was the only option for. + def provider_authorization_flow + return :authorization_code unless @provider.respond_to?(:authorization_flow) + + @provider.authorization_flow + end + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 139c07f..ce10dad 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -3,9 +3,11 @@ module MCP class Client module OAuth - # Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via - # the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK - # and httpx.Auth-based provider in the Python SDK. + # Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow, + # handed to `MCP::Client::HTTP` via the `oauth:` keyword. + # Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider + # in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant, + # use `ClientCredentialsProvider` instead. # # Required keyword arguments: # - `client_metadata` - Hash sent to the authorization server's Dynamic Client @@ -36,6 +38,8 @@ module MCP # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. class Provider + include StorageBackedProvider + # Raised when `Provider#initialize` is called with a `redirect_uri` that # is neither HTTPS nor a loopback `http://` URL, per the MCP # authorization spec's Communication Security requirement. @@ -102,28 +106,11 @@ module MCP @client_id_metadata_document_url = client_id_metadata_document_url end - def access_token - tokens&.dig("access_token") || tokens&.dig(:access_token) - end - - def tokens - @storage.tokens - end - - def save_tokens(tokens) - @storage.save_tokens(tokens) - end - - def client_information - @storage.client_information - end - - def save_client_information(info) - @storage.save_client_information(info) - end - - def clear_tokens! - @storage.save_tokens(nil) + # Identifies the OAuth flow this provider drives. + # `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`, + # which is protocol metadata for the authorization server, not an SDK control signal. + def authorization_flow + :authorization_code end end end diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb new file mode 100644 index 0000000..499db6b --- /dev/null +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Shared token/credential persistence for the OAuth provider classes + # (`Provider` for the authorization-code flow and `ClientCredentialsProvider` + # for the client_credentials flow). The two grants differ in how they authenticate, + # but both read and write the same two pieces of state through a `storage` object: + # the token response and the client information. This module supplies that delegation + # so the `Flow` orchestrator can treat any provider uniformly. + # + # Including classes must set `@storage` to an object responding to `tokens`, + # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` + # (see `InMemoryStorage`). + module StorageBackedProvider + def access_token + tokens&.dig("access_token") || tokens&.dig(:access_token) + end + + def tokens + @storage.tokens + end + + def save_tokens(tokens) + @storage.save_tokens(tokens) + end + + def client_information + @storage.client_information + end + + def save_client_information(info) + @storage.save_client_information(info) + end + + def clear_tokens! + @storage.save_tokens(nil) + end + end + end + end +end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb new file mode 100644 index 0000000..c62395f --- /dev/null +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class ClientCredentialsProviderTest < Minitest::Test + def test_initialize_stores_credentials_as_client_information + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("cc-secret", info["client_secret"]) + assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + end + + def test_authorization_flow_is_client_credentials + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + assert_equal(:client_credentials, provider.authorization_flow) + end + + def test_initialize_accepts_client_secret_post + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "client_secret_post", + ) + + assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"]) + end + + def test_initialize_rejects_missing_client_id + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: value, client_secret: "cc-secret") + end + end + end + + def test_initialize_rejects_missing_client_secret + # The client_credentials grant is for confidential clients, so a credential is mandatory. + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: value) + end + end + end + + def test_initialize_rejects_none_auth_method + # An unauthenticated client_credentials request is meaningless. + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "none", + ) + end + end + + def test_token_helpers_delegate_to_storage + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + provider.save_tokens("access_token" => "cc-token") + + assert_equal("cc-token", provider.access_token) + provider.clear_tokens! + assert_nil(provider.tokens) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 8c0bcc1..d36c5e9 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,6 +68,14 @@ module MCP WebMock.reset! end + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: token_endpoint_auth_method, + ) + end + # Runs the full authorization flow and returns the `scope` query parameter # sent on the authorization request. The caller stubs the AS metadata; # this helper supplies a provider whose `grant_types` and optional pre-set @@ -96,6 +104,102 @@ module MCP captured_scope end + def test_run_uses_client_credentials_grant_for_client_credentials_provider + provider = client_credentials_provider + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + # No authorization-code machinery: /authorize is never contacted + # (no stub exists for it, so a request would raise), and DCR is not + # run because credentials are pre-registered. + assert_not_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + expected_basic = "Basic " + Base64.strict_encode64("cc-client:cc-secret") + form["grant_type"] == "client_credentials" && + !form.key?("code") && + !form.key?("code_verifier") && + req.headers["Authorization"] == expected_basic + end + end + + def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body + # `client_secret_post` puts the credentials in the form body rather + # than an HTTP Basic header (RFC 6749 Section 2.3.1). + provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_post") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "client_credentials" && + form["client_id"] == "cc-client" && + form["client_secret"] == "cc-secret" && + req.headers["Authorization"].nil? + end + end + + def test_run_client_credentials_raises_clean_error_when_client_information_missing + # The constructor always stores credentials, but if a custom storage loses them + # the grant must fail with a domain error rather than a `NoMethodError` from + # the authorization-code registration helper. + provider = client_credentials_provider + provider.storage.save_client_information(nil) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_credentials grant/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_client_credentials_requests_scope_from_prm_scopes_supported + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write"], + ), + ) + + provider = client_credentials_provider + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["scope"] == "mcp:read mcp:write" + end + end + + def test_run_uses_authorization_code_grant_for_default_provider + # A standard `Provider` declares `authorization_flow == :authorization_code`, + # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code", "client_credentials"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "authorization_code" + end + end + def test_run_completes_full_authorization_flow captured_authorization_url = nil state_value = nil diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index 2b17ed5..3db9427 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -181,6 +181,12 @@ module MCP end end end + + def test_authorization_flow_is_authorization_code + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_equal(:authorization_code, provider.authorization_flow) + end end end end From 18761cf367572d4288ead9a1d5c75dec207209b4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 13 Jun 2026 01:52:41 +0900 Subject: [PATCH 100/247] Preserve the Request ID in Invalid Request Error Responses ## Motivation and Context Resolves #398. `JsonRpcHandler` returned `"id": null` for JSON-RPC envelope errors (wrong or missing `jsonrpc` version, non-string `method`) even when the incoming payload carried a concrete, valid request id. From the client's perspective the original request stayed pending forever, since no response with the matching id ever arrived: ``` {"jsonrpc":"1.0","id":3,"method":"ping","params":{}} => {"jsonrpc":"2.0","id":null,"error":{"code":-32600,...}} ``` Per JSON-RPC 2.0 (Response object, `id`), the response id MUST be the same as the request's id; null is reserved for requests whose id could not be detected (e.g. Parse error). The envelope validation in `process_request` runs after JSON parsing with the request hash in hand, so the id is detectable - it was simply discarded by passing the `:unknown_id` sentinel unconditionally. The fix passes the request's id to `error_response` whenever the request carries one. The existing guards keep every other case intact: `error_response` already nils out ids that fail validation (so a type-invalid or pattern-violating id still yields `"id": null`, preserving the XSS-prevention policy on echoed ids), and the `:unknown_id` sentinel is kept for requests without an id so an Invalid Request error still produces a null-id response, matching the spec's own example. Parse errors and non-object requests are unchanged (null is correct there), and batch entries pick up the fix automatically since each entry goes through `process_request`. For reference, the TypeScript and Python SDKs currently respond with `"id": null` in these cases as well, because both validate the whole message against a schema (zod / pydantic) before extracting the id. This change is driven by the JSON-RPC 2.0 requirement rather than parity; the Ruby transport layer already echoes the request id where recoverable (`invalid_request_response(request_id:)` in `StreamableHTTPTransport`), and this closes the remaining gap in `JsonRpcHandler`. ## How Has This Been Tested? - Reproduced the three payloads from #398 before and after the fix; they now return `"id":3`, `"id":4`, and `"id":8` respectively - New and updated unit tests covering: id preserved for wrong version, missing `jsonrpc`, numeric `method`, and `rpc.`-prefixed `method`; id preserved for an invalid entry inside a batch; null id kept for requests without an id, with an explicit null id, and with an id that fails validation ## Breaking Changes None in the protocol sense - this aligns the error responses with JSON-RPC 2.0. Clients that matched `"id": null` on these envelope errors will now see the request's own id, which is what allows them to correlate the error with the pending request. --- lib/json_rpc_handler.rb | 9 +++- test/json_rpc_handler_test.rb | 77 ++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 977afa3..2c6c836 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -73,13 +73,18 @@ module JsonRpcHandler error = if !valid_version?(request[:jsonrpc]) "JSON-RPC version must be 2.0" - elsif !valid_id?(request[:id], id_validation_pattern) + elsif !valid_id?(id, id_validation_pattern) "Request ID must match validation pattern, or be an integer or null" elsif !valid_method_name?(request[:method]) 'Method name must be a string and not start with "rpc."' end - return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + # Per JSON-RPC 2.0 (Response object, `id`), the error response must carry + # the same id as the request when the id could be detected; null is only + # for requests whose id could not be determined. `error_response` nils out + # ids that fail validation, and the `:unknown_id` sentinel keeps a response + # (with a null id) being emitted when the request carried no id at all. + return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: { code: ErrorCode::INVALID_REQUEST, message: "Invalid Request", data: error, diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index c1f08f1..83a18df 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -43,6 +43,18 @@ describe JsonRpcHandler do message: "Invalid Request", data: "JSON-RPC version must be 2.0", } + assert_equal 1, @response[:id] + end + + it "returns an error preserving the request id when jsonrpc is missing" do + handle id: 4, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_equal 4, @response[:id] end # method @@ -58,6 +70,7 @@ describe JsonRpcHandler do message: "Invalid Request", data: 'Method name must be a string and not start with "rpc."', } + assert_equal 1, @response[:id] end it "returns an error when method begins with 'rpc.'" do @@ -68,6 +81,7 @@ describe JsonRpcHandler do message: "Invalid Request", data: 'Method name must be a string and not start with "rpc."', } + assert_equal 1, @response[:id] end # params @@ -315,6 +329,50 @@ describe JsonRpcHandler do assert_nil @response[:id] end + it "returns the same request id on an Invalid Request error when the id is detectable" do + handle jsonrpc: "1.0", id: 3, method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_equal 3, @response[:id] + end + + it "returns nil for id on an Invalid Request error when the request has no id" do + handle jsonrpc: "1.0", method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + + it "returns nil for id on an Invalid Request error when the request id is explicitly null" do + handle jsonrpc: "1.0", id: nil, method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + + it "returns nil for id on an Invalid Request error when the id fails validation" do + handle jsonrpc: "1.0", id: "", method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + # 5.1 Error object # # When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a @@ -610,6 +668,20 @@ describe JsonRpcHandler do assert_nil @response end + + it "preserves the request id of an invalid entry within a batch" do + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + { jsonrpc: "1.0", id: 200, method: "add", params: { a: 3, b: 4 } }, + ] + + assert @response.is_a?(Array) + assert_equal [100, 200], @response.map { |result| result[:id] } + assert_equal 3, @response.first[:result] + assert_equal(-32600, @response.last.dig(:error, :code)) + end end # 7 Examples @@ -756,10 +828,11 @@ describe JsonRpcHandler do assert_nil @response end - it "returns an error with the id set to nil when the request is invalid" do + it "returns an error preserving the request id when the request is invalid" do handle_json({ jsonrpc: "0.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json) - assert_nil @response[:id] + assert_equal 1, @response[:id] + assert_equal(-32600, @response.dig(:error, :code)) end end From 8f10f8e542a942b60e9e06e9a95fd1a73952d3ec Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 9 Jun 2026 01:00:38 +0900 Subject: [PATCH 101/247] Release 0.19.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 8 ++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afed492..d0a5e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0] - 2026-06-13 + +### Added + +- Support Client ID Metadata Documents in OAuth client (#361) +- Request `offline_access` scope when supported (#365) +- Add `size` field to `MCP::Resource` per MCP specification (#393) + ## [0.18.0] - 2026-05-30 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index eea2cb4..cb6585c 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.18.0" + VERSION = "0.19.0" end From d096b8a7a93fc5f63be2557538742ad184fcc09e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:26:16 +0900 Subject: [PATCH 102/247] Standardize Resource Not Found Errors on -32602 with URI Data per SEP-2164 ## Motivation and Context SEP-2164 (modelcontextprotocol/modelcontextprotocol#2164, merged for the 2026-07-28 spec release) standardizes the resource-not-found error on the JSON-RPC Invalid Params code (-32602), dropping the legacy -32002 code, and both reference SDK implementations attach the requested URI as structured error data (typescript-sdk#2267 throws `InvalidParams` with `data: { uri }`; python-sdk#2344 adds a `ResourceNotFoundError` that maps to `code=-32602, data={"uri": ...}`). The Ruby server already used -32602 (via `error_type: :invalid_params`) for its built-in not-found path (`completion/complete` with an unknown `ref/resource` URI), but the response carried no structured data and the generic "Invalid params" message, and there was no public error class for `resources_read_handler` blocks to raise. The Ruby client never special-cased -32002, so nothing needed removal there. - New public `MCP::Server::ResourceNotFoundError` (modeled on the `URLElicitationRequiredError` precedent): code -32602, message `"Resource not found: "`, and `data: { uri: }`. Custom `resources_read_handler` blocks raise it for unknown URIs; the README documents the pattern. - The `completion/complete` `ref/resource` not-found path now raises this class, so its error response gains `data: { uri: }` and a descriptive message instead of the generic "Invalid params" text. - The default `resources/read` behavior (returning empty contents for unknown URIs when no handler is registered) is intentionally unchanged; raising there would alter existing wire behavior. Resolves #379. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb`: - `resources/read` with a handler raising `ResourceNotFoundError` responds with code -32602, message `"Resource not found: file:///missing.txt"`, and `data: { uri: "file:///missing.txt" }` - `completion/complete` with an unknown `ref/resource` URI carries the same code, message, and structured data New test in `test/mcp/client_test.rb` asserts `Client#read_resource` surfaces the -32602 code and the `data` hash unmodified through `Client::ServerError` (regression: no special-casing swallows it). The pre-existing completion error-code tests pass unchanged. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None at the JSON-RPC code level (-32602 before and after). The `completion/complete` resource-not-found error response changes shape within the error object: `message` becomes `"Resource not found: "` instead of `"Invalid params"`, and `data` becomes `{ uri: }` instead of the message string. Both members are advisory per JSON-RPC 2.0. --- README.md | 13 +++++++++++++ lib/mcp/server.rb | 28 ++++++++++++++++++++++++++- test/mcp/client_test.rb | 21 ++++++++++++++++++++ test/mcp/server_test.rb | 43 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3010488..eb10dc5 100644 --- a/README.md +++ b/README.md @@ -921,6 +921,19 @@ end otherwise `resources/read` requests will be a no-op. +For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. +Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) +carrying the requested URI in the error `data` member: + +```ruby +server.resources_read_handler do |params| + resource = lookup(params[:uri]) + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource + + [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }] +end +``` + ### Resource Templates The `MCP::ResourceTemplate` class provides a way to register resource templates with the server. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c1b8be1..f93bea8 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -58,6 +58,32 @@ module MCP end end + # Raised when a requested resource URI does not exist. Per SEP-2164, + # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # with the requested URI in the error `data` member. Raise this from + # a `resources_read_handler` block for unknown URIs: + # + # server.resources_read_handler do |params| + # raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless known?(params[:uri]) + # do_something(params[:uri]) + # end + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164 + class ResourceNotFoundError < RequestHandlerError + def initialize(uri, request = nil) + # The explicit `error_code` keeps the descriptive message in the JSON-RPC + # error response; `error_type: :invalid_params` alone would replace it + # with the generic "Invalid params" string. + super( + "Resource not found: #{uri}", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + error_data: { uri: uri }, + ) + end + end + class MethodAlreadyDefinedError < StandardError attr_reader :method_name @@ -846,7 +872,7 @@ module MCP uri = ref[:uri] found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri } unless found - raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params) + raise ResourceNotFoundError.new(uri, params) end else raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 3c69081..cdf9d87 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -610,6 +610,27 @@ module MCP assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") } end + def test_read_resource_surfaces_resource_not_found_code_and_data + # Per SEP-2164, servers report unknown resource URIs with -32602 and + # the URI in the error `data`; the client must expose both unmodified. + transport = mock + mock_response = { + "error" => { + "code" => -32_602, + "message" => "Resource not found: file:///missing.txt", + "data" => { "uri" => "file:///missing.txt" }, + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing.txt") } + + assert_equal(-32_602, error.code) + assert_equal({ "uri" => "file:///missing.txt" }, error.data) + end + def test_get_prompt_raises_server_error_on_error_response transport = mock mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 35a72a7..b40f604 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -724,6 +724,25 @@ module MCP ) end + test "#handle resources/read returns -32602 with the uri in error data when the handler raises ResourceNotFoundError" do + # Per SEP-2164, resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # and carry the requested URI in `data`. + @server.resources_read_handler do |request| + raise Server::ResourceNotFoundError.new(request[:uri], request) + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///missing.txt" }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: file:///missing.txt", response[:error][:message]) + assert_equal({ uri: "file:///missing.txt" }, response[:error][:data]) + end + test "#handle resources/templates/list returns a list of resource templates" do request = { jsonrpc: "2.0", @@ -2206,6 +2225,30 @@ module MCP assert_equal(-32_602, response[:error][:code]) end + test "#handle completion/complete resource-not-found error carries the uri in error data" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "unknown://template" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: unknown://template", response[:error][:message]) + assert_equal({ uri: "unknown://template" }, response[:error][:data]) + end + test "#handle completion/complete returns error for invalid ref type" do server = Server.new( name: "test_server", From 64219be2c8e56b7e56cadec4acb2fc639da3c21b Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Wed, 10 Jun 2026 11:10:25 +0900 Subject: [PATCH 103/247] Add `annotations` field to `MCP::Resource` and `MCP::ResourceTemplate` per MCP specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation and Context The MCP specification defines an optional `annotations` field on both the `Resource` and `ResourceTemplate` types, but the Ruby SDK is the only official SDK that does not wire it up — TypeScript, Python, and PHP all implement it. https://modelcontextprotocol.io/specification/2025-11-25/server/resources#annotations The `MCP::Annotations` class already exists; this commit connects it. The field accepts an `MCP::Annotations` instance rather than a raw hash, for consistency with other typed fields and to get `lastModified` key normalization for free. ## How Has This Been Tested? Added tests in `test/mcp/resource_test.rb` and `test/mcp/resource_template_test.rb`: - `#to_h` omits `annotations` when nil - `#to_h` includes `annotations` when present (also asserting that `last_modified` is serialized as `lastModified`) The full unit suite and RuboCop pass locally. ## Breaking Changes None. `annotations:` is a new optional keyword argument defaulting to `nil`, and it is omitted from `#to_h` output when not set. --- lib/mcp/resource.rb | 6 ++++-- lib/mcp/resource_template.rb | 6 ++++-- test/mcp/resource_template_test.rb | 18 ++++++++++++++++++ test/mcp/resource_test.rb | 14 ++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index e4e55fc..8b5f469 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -5,15 +5,16 @@ require_relative "resource/embedded" module MCP class Resource - attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta - def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil) + def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil) @uri = uri @name = name @title = title @description = description @icons = icons @mime_type = mime_type + @annotations = annotations @size = size @meta = meta end @@ -26,6 +27,7 @@ module MCP description: description, icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + annotations: annotations&.to_h, size: size, _meta: meta, }.compact diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index b23ecd0..e078174 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,15 +2,16 @@ module MCP class ResourceTemplate - attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta - def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil) + def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil) @uri_template = uri_template @name = name @title = title @description = description @icons = icons @mime_type = mime_type + @annotations = annotations @meta = meta end @@ -22,6 +23,7 @@ module MCP description: description, icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + annotations: annotations&.to_h, _meta: meta, }.compact end diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb index 8d25be0..4a6ef5f 100644 --- a/test/mcp/resource_template_test.rb +++ b/test/mcp/resource_template_test.rb @@ -53,5 +53,23 @@ module MCP assert_equal meta, resource_template.to_h[:_meta] end + + test "#to_h omits annotations when nil" do + resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_annotations") + + refute resource_template.to_h.key?(:annotations) + end + + test "#to_h includes annotations when present" do + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: "2025-01-12T15:00:58Z") + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "template_with_annotations", + annotations: annotations, + ) + + expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } + assert_equal expected, resource_template.to_h[:annotations] + end end end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index a3d6a1b..a721ad3 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -67,5 +67,19 @@ module MCP assert_equal 0, resource.to_h[:size] end + + test "#to_h omits annotations when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_annotations") + + refute resource.to_h.key?(:annotations) + end + + test "#to_h includes annotations when present" do + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: "2025-01-12T15:00:58Z") + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_annotations", annotations: annotations) + + expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } + assert_equal expected, resource.to_h[:annotations] + end end end From 4fb0049fd4a2798ff0d79d5b02ad968dbb8675ca Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 14 Jun 2026 16:56:55 +0900 Subject: [PATCH 104/247] Release 0.20.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 21 +++++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a5e79..c020db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-06-14 + +### Added + +- Support W3C Trace Context Propagation via `_meta` per SEP-414 (#397) +- Support OAuth `client_credentials` grant in OAuth client (#399) +- Add `annotations` field to `MCP::Resource` and `MCP::ResourceTemplate` per MCP specification (#403) + +### Changed + +- Re-run OAuth flow on 403 `insufficient_scope` (step-up) (#368) +- Speed up `Tool::Schema` validation by 5x to 100x (#369) +- Use JSON-RPC error envelope for `StreamableHTTPTransport` errors (#371) +- Pin RFC 8414 default well-known suffix per SEP-2351 (#395) +- Default missing `MCP-Protocol-Version` to `2025-03-26` in `StreamableHTTPTransport` (#392) + +### Fixed + +- Preserve the request ID in invalid request error responses (#400) +- Standardize Resource Not Found errors on -32602 with URI data per SEP-2164 (#402) + ## [0.19.0] - 2026-06-13 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index cb6585c..b885c8e 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.19.0" + VERSION = "0.20.0" end From 2c6213915424eb31e0cccb192b59e0c190b7f528 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 14 Jun 2026 17:07:58 +0900 Subject: [PATCH 105/247] Support Capability Extensions per SEP-2133 ## Motivation and Context SEP-2133 (modelcontextprotocol/modelcontextprotocol#2133, merged for the 2026-07-28 spec release) establishes the extensions framework for MCP: both `ClientCapabilities` and `ServerCapabilities` gain an optional `extensions` member keyed by reverse-DNS extension identifiers (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`), with arbitrary extension-defined configuration objects as values and an empty object meaning "supported with no settings". Official extensions such as MCP Apps (SEP-1865) and the Tasks extension (SEP-2663) are layered on this field. This mirrors the shape the TypeScript SDK merged (typescript-sdk#1630): plain declaration and passthrough, no registrar API and no key-format validation (the naming convention is documented instead). - `MCP::Server::Capabilities` gains `support_extensions` (repeated calls merge), accepts an `:extensions` key in its constructor hash, and includes `extensions` in `to_h`. The class was previously dead code: it was never required and `Server.new` only took plain hashes. It is now required by `lib/mcp/server.rb`, and `Server.new(capabilities:)` accepts either a plain Hash (as before) or a `Capabilities` instance. - Server-declared extensions appear in the `initialize` result; extensions the client declares during `initialize` were already stored verbatim and are readable via `Server#client_capabilities[:extensions]` and `ServerSession#client_capabilities[:extensions]` (now covered by tests). - `MCP::Client#connect(capabilities:)` already passes capabilities through verbatim; its documentation now covers the `extensions` member. Resolves #378. ## How Has This Been Tested? - New `test/mcp/server/capabilities_test.rb` covers the previously untested `Capabilities` class: empty default, constructor round-trip, extensions via constructor and `support_extensions`, merge semantics, nil tolerance, and omission from `to_h` when never declared. - New tests in `test/mcp/server_test.rb` assert that the `initialize` result carries declared extensions (from both a plain hash and a `Capabilities` instance) and that client-declared extensions are readable via `Server#client_capabilities` and `ServerSession#client_capabilities`. ## Breaking Changes None. `extensions` is additive and appears on the wire only when declared; servers constructed with plain capability hashes behave exactly as before. --- README.md | 25 ++++++++++ lib/mcp/client.rb | 4 +- lib/mcp/server.rb | 8 +++- lib/mcp/server/capabilities.rb | 14 ++++++ test/mcp/server/capabilities_test.rb | 71 ++++++++++++++++++++++++++++ test/mcp/server_test.rb | 69 +++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 test/mcp/server/capabilities_test.rb diff --git a/README.md b/README.md index d615429..31faa24 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,31 @@ server = MCP::Server.new( ) ``` +### Capability Extensions + +Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. +Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); +values are extension-defined configuration objects, with `{}` meaning "supported with no settings". + +On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_extensions("com.example/feature" => { enabled: true }) + +server = MCP::Server.new(name: "my_server", capabilities: capabilities) +``` + +The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are +readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports). + +On the client, pass extensions through `connect`: + +```ruby +client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) +``` + ### Server Context and Configuration Block Data #### `server_context` diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index ea71d01..68c2ee4 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -77,7 +77,9 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # @param protocol_version [String, nil] Protocol version to offer. - # @param capabilities [Hash] Capabilities advertised by the client. + # @param capabilities [Hash] Capabilities advertised by the client. May include + # an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers, + # e.g. `{ extensions: { "com.example/feature" => {} } }`. # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport # does not expose an explicit handshake. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index f93bea8..e734b7b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -8,6 +8,7 @@ require_relative "methods" require_relative "logging_message_notification" require_relative "progress" require_relative "server_context" +require_relative "server/capabilities" require_relative "server/pagination" require_relative "server/transports" @@ -142,7 +143,12 @@ module MCP validate! - @capabilities = capabilities || default_capabilities + # Accept either a plain Hash or an `MCP::Server::Capabilities` builder. + @capabilities = if capabilities.is_a?(Capabilities) + capabilities.to_h + else + capabilities || default_capabilities + end @client_capabilities = nil @logging_message_notification = nil diff --git a/lib/mcp/server/capabilities.rb b/lib/mcp/server/capabilities.rb index 6647eb9..0efe015 100644 --- a/lib/mcp/server/capabilities.rb +++ b/lib/mcp/server/capabilities.rb @@ -6,6 +6,7 @@ module MCP def initialize(capabilities_hash = nil) @completions = nil @experimental = nil + @extensions = nil @logging = nil @prompts = nil @resources = nil @@ -14,6 +15,7 @@ module MCP if capabilities_hash support_completions if capabilities_hash.key?(:completions) support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental) + support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions) support_logging if capabilities_hash.key?(:logging) if capabilities_hash.key?(:prompts) @@ -45,6 +47,17 @@ module MCP @experimental = config || {} end + # Declares support for capability extensions per SEP-2133. Keys are + # extension identifiers using the reverse-DNS prefix convention + # (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); + # values are arbitrary extension-defined configuration objects, + # with an empty hash meaning "supported with no settings". + # Repeated calls merge, so several extensions can be declared independently. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133 + def support_extensions(extensions = {}) + @extensions = (@extensions || {}).merge(extensions || {}) + end + def support_logging @logging ||= {} end @@ -85,6 +98,7 @@ module MCP { completions: @completions, experimental: @experimental, + extensions: @extensions, logging: @logging, prompts: @prompts, resources: @resources, diff --git a/test/mcp/server/capabilities_test.rb b/test/mcp/server/capabilities_test.rb new file mode 100644 index 0000000..93e7e5f --- /dev/null +++ b/test/mcp/server/capabilities_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class CapabilitiesTest < ActiveSupport::TestCase + test "to_h omits everything by default" do + assert_empty(Capabilities.new.to_h) + end + + test "constructor accepts a capabilities hash" do + capabilities = Capabilities.new( + completions: {}, + logging: {}, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + tools: { listChanged: true }, + ) + + assert_equal( + { + completions: {}, + logging: {}, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + tools: { listChanged: true }, + }, + capabilities.to_h, + ) + end + + test "constructor accepts extensions" do + capabilities = Capabilities.new( + tools: {}, + extensions: { "com.example/feature" => { enabled: true } }, + ) + + assert_equal({ "com.example/feature" => { enabled: true } }, capabilities.to_h[:extensions]) + end + + test "support_extensions merges repeated declarations" do + capabilities = Capabilities.new + capabilities.support_extensions("com.example/feature" => { enabled: true }) + capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) + + assert_equal( + { "com.example/feature" => { enabled: true }, "io.modelcontextprotocol/tasks" => {} }, + capabilities.to_h[:extensions], + ) + end + + test "support_extensions with no arguments declares an empty extensions object" do + capabilities = Capabilities.new + capabilities.support_extensions + + assert_equal({}, capabilities.to_h[:extensions]) + end + + test "support_extensions tolerates nil" do + capabilities = Capabilities.new(extensions: nil) + + assert_equal({}, capabilities.to_h[:extensions]) + end + + test "to_h omits extensions when never declared" do + refute(Capabilities.new(tools: {}).to_h.key?(:extensions)) + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index b40f604..43ece74 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -159,6 +159,75 @@ module MCP assert_instrumentation_data({ method: "initialize" }) end + test "#handle initialize result carries declared capability extensions" do + server = Server.new( + name: "extensions_test", + capabilities: { + tools: { listChanged: true }, + extensions: { "com.example/feature" => { enabled: true } }, + }, + ) + + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + + assert_equal( + { "com.example/feature" => { enabled: true } }, + response.dig(:result, :capabilities, :extensions), + ) + end + + test "Server.new accepts an MCP::Server::Capabilities instance" do + capabilities = Server::Capabilities.new + capabilities.support_tools + capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) + + server = Server.new(name: "extensions_test", capabilities: capabilities) + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + + assert_equal( + { + tools: {}, + extensions: { "io.modelcontextprotocol/tasks" => {} }, + }, + response.dig(:result, :capabilities), + ) + end + + test "client-declared capability extensions are readable via client_capabilities" do + extensions = { "com.example/feature": { enabled: true } } + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + clientInfo: { name: "test_client", version: "1.0.0" }, + capabilities: { extensions: extensions }, + }, + } + + @server.handle(request) + + assert_equal extensions, @server.client_capabilities[:extensions] + end + + test "client-declared capability extensions are readable via the session" do + session = ServerSession.new(server: @server, transport: mock) + extensions = { "com.example/feature": {} } + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + clientInfo: { name: "test_client", version: "1.0.0" }, + capabilities: { extensions: extensions }, + }, + } + + @server.handle(request, session: session) + + assert_equal extensions, session.client_capabilities[:extensions] + end + test "#handle initialize request with clientInfo includes client in instrumentation data" do client_info = { name: "test_client", version: "1.0.0" } request = { From 3093b3ee3fba3df1fc4d45d819f532c5335eac45 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 14 Jun 2026 23:32:13 +0900 Subject: [PATCH 106/247] Set OIDC `application_type` on Dynamic Client Registration per SEP-837 ## Motivation and Context SEP-837 (modelcontextprotocol/modelcontextprotocol#837, merged for the 2026-07-28 spec release) updates the authorization spec to require MCP clients to specify an appropriate OIDC `application_type` (`"native"` vs `"web"`) during Dynamic Client Registration, so the authorization server can apply the matching redirect URI policy and native and web clients do not collide on redirect URI validation rules. This mirrors the approach taken by the TypeScript SDK (typescript-sdk#2266) and the Python SDK (python-sdk#2784): - `MCP::Client::OAuth::Discovery.infer_application_type(redirect_uris)` returns `"native"` when every redirect URI is a native-app URI (a custom non-http(s) scheme per RFC 8252 Section 7.1, or an http(s) URI with a loopback host per RFC 8252 Section 7.3) and `"web"` otherwise, including for nil, empty, or unparseable input. Loopback detection reuses the existing `loopback_host?` helper, so lookalike hosts such as `localhost.example.com` or `127.attacker.com` are not treated as native. - `Flow#ensure_client_registered` now posts the metadata through the new `registration_client_metadata` helper, which merges the inferred `application_type` into the DCR request body only when the user did not set one explicitly (symbol or string key); an explicit value always wins. Resolves #375. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/discovery_test.rb` cover the inference: loopback http URIs (IPv4, IPv6, `localhost`), custom schemes, https URIs, mixed lists, the `localhost.example.com` lookalike, nil/empty input, and unparseable URIs. New tests in `test/mcp/client/oauth/flow_test.rb` assert on the actual DCR request body via WebMock `assert_requested`: loopback redirect URI registers `"application_type": "native"`, an HTTPS redirect URI registers `"web"`, and an explicit `application_type` (symbol- or string-keyed) is never overridden. ## Breaking Changes None. The DCR request body gains an `application_type` member only when the user has not specified one; RFC 7591 requires authorization servers to accept and use known metadata fields, and OIDC-registration servers already default this field to `"web"` when absent. --- README.md | 2 + lib/mcp/client/oauth/discovery.rb | 31 ++++++++++++++ lib/mcp/client/oauth/flow.rb | 16 ++++++- lib/mcp/client/oauth/provider.rb | 3 ++ test/mcp/client/oauth/discovery_test.rb | 33 +++++++++++++++ test/mcp/client/oauth/flow_test.rb | 56 +++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31faa24..d379b46 100644 --- a/README.md +++ b/README.md @@ -2029,6 +2029,8 @@ Required keyword arguments to `Provider.new`: - `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. + When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); + an explicit value always wins. - `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. - `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. - `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`. diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index d7ab4a9..d1540b9 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -237,6 +237,23 @@ module MCP false end + # Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`. + # Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration + # so the authorization server can apply the matching redirect URI policy. + # + # Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1) + # or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3). + # Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837 + # - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + def infer_application_type(redirect_uris) + uris = Array(redirect_uris) + return "web" if uris.empty? + + uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web" + end + # Like `canonicalize_url` but also strips query string, fragment, and # userinfo. This variant is used for identity comparison against # the request URL Faraday actually sends, which differs from the value @@ -345,6 +362,20 @@ module MCP nil end + # A redirect URI counts as native when it uses a custom non-http(s) scheme + # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is + # a loopback address. A URI without a scheme or one that fails to parse is not native. + def native_redirect_uri?(url) + uri = URI.parse(url.to_s) + scheme = uri.scheme&.downcase + return false if scheme.nil? + return loopback_host?(uri.host) if ["http", "https"].include?(scheme) + + true + rescue URI::InvalidURIError + false + end + def base_url(uri) port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : "" "#{uri.scheme}://#{uri.host}#{port_part}" diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index ed76ee5..54ab17a 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -367,7 +367,7 @@ module MCP end response = begin - http_post_json(registration_endpoint, @provider.client_metadata) + http_post_json(registration_endpoint, registration_client_metadata) rescue Faraday::Error => e raise AuthorizationError, "Dynamic client registration failed: #{e.class}: #{e.message}." @@ -393,6 +393,20 @@ module MCP info end + # Returns the client metadata to submit on Dynamic Client Registration. + # Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type` + # so the authorization server can apply the matching redirect URI policy. + # When the user did not set one explicitly, infer `"native"` vs `"web"` from + # the registered `redirect_uris`; an explicit value always wins. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837 + def registration_client_metadata + metadata = @provider.client_metadata + return metadata if metadata[:application_type] || metadata["application_type"] + + redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"] + metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris)) + end + # Reads `key` from a `client_information` hash that may use either string or # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built # `{ client_id:, client_secret: }` and have both work. diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index ce10dad..4864ae4 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -13,6 +13,9 @@ module MCP # - `client_metadata` - Hash sent to the authorization server's Dynamic Client # Registration endpoint. Must include at minimum `redirect_uris`, # `grant_types`, `response_types`, and `token_endpoint_auth_method`. + # When `application_type` is omitted, the SDK infers `"native"` or `"web"` + # from `redirect_uris` per SEP-837 before registering; an explicit value + # always wins. # - `redirect_uri` - String: the redirect URI used for the authorization # request. Must be one of `redirect_uris` in `client_metadata`. # - `redirect_handler` - Callable invoked with the fully-built authorization diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index 245e9b7..4b29184 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -158,6 +158,39 @@ module MCP ) end + def test_infer_application_type_returns_native_for_loopback_http_redirect_uris + uris = ["http://localhost:0/callback", "http://127.0.0.1:8080/cb", "http://[::1]/cb"] + + assert_equal("native", Discovery.infer_application_type(uris)) + end + + def test_infer_application_type_returns_native_for_custom_scheme_redirect_uris + assert_equal("native", Discovery.infer_application_type(["com.example.app:/oauth/callback"])) + end + + def test_infer_application_type_returns_web_for_https_redirect_uris + assert_equal("web", Discovery.infer_application_type(["https://app.example.com/callback"])) + end + + def test_infer_application_type_returns_web_when_any_redirect_uri_is_not_native + uris = ["http://localhost:0/callback", "https://app.example.com/callback"] + + assert_equal("web", Discovery.infer_application_type(uris)) + end + + def test_infer_application_type_returns_web_for_localhost_lookalike_host + assert_equal("web", Discovery.infer_application_type(["http://localhost.example.com/cb"])) + end + + def test_infer_application_type_returns_web_for_nil_or_empty_redirect_uris + assert_equal("web", Discovery.infer_application_type(nil)) + assert_equal("web", Discovery.infer_application_type([])) + end + + def test_infer_application_type_returns_web_for_unparseable_redirect_uri + assert_equal("web", Discovery.infer_application_type(["http://[invalid"])) + end + def test_canonicalize_url_normalizes_scheme_host_port_and_path assert_equal( "https://srv.example.com/mcp", diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index d36c5e9..5aa2dfe 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -200,6 +200,62 @@ module MCP end end + # Runs the full authorization flow with a minimal provider so tests can assert on + # the Dynamic Client Registration request body. The default loopback redirect URI + # exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises + # the `"web"` inference. + def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}) + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: [redirect_uri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }.merge(client_metadata_extra), + redirect_uri: redirect_uri, + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + def test_run_registers_native_application_type_for_loopback_redirect_uri + run_authorization_flow + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "native" + end + end + + def test_run_registers_web_application_type_for_https_redirect_uri + run_authorization_flow(redirect_uri: "https://app.example.com/callback") + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "web" + end + end + + def test_run_does_not_override_explicit_application_type + run_authorization_flow(client_metadata_extra: { application_type: "web" }) + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "web" + end + end + + def test_run_does_not_override_explicit_string_keyed_application_type + run_authorization_flow( + redirect_uri: "https://app.example.com/callback", + client_metadata_extra: { "application_type" => "native" }, + ) + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "native" + end + end + def test_run_completes_full_authorization_flow captured_authorization_url = nil state_value = nil From 460adafb17e99c20cce072b63c1878f277cdf716 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sun, 14 Jun 2026 17:46:38 +0200 Subject: [PATCH 107/247] chore: require rubocop-minitest 0.39 Require rubocop-minitest 0.39.0 or newer so Minitest/AssertEmptyLiteral uses its newer default disabled state. --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 8b76222..c9f42cb 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ source "https://rubygems.org" gemspec # Specify development dependencies below -gem "rubocop-minitest", require: false +gem "rubocop-minitest", ">= 0.39.0", require: false gem "rubocop-rake", require: false gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1" From cd5226da2ef2152b63f00405e71f89ec23688f42 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 15 Jun 2026 10:30:14 +0200 Subject: [PATCH 108/247] test: close stdio stderr pipe --- test/mcp/client/stdio_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 6b1b92f..ce01fb9 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -73,6 +73,7 @@ module MCP stdin_write.close stdout_read.close stdout_write.close + stderr_read.close end def test_send_request_initializes_session_on_first_call From c588582acc76e02adc538e33fa1f740e3cc6227f Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Sun, 14 Jun 2026 16:25:44 +0900 Subject: [PATCH 109/247] Add range validation for `MCP::Annotations#priority` per MCP specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation and Context The MCP specification constrains `Annotations.priority` to the 0–1 range (`@minimum 0`, `@maximum 1`), but the Ruby SDK accepts any value. The other official SDKs already enforce this (TypeScript, Python, and PHP). This follows the existing pattern in `MCP::Icon`, which validates its constrained `theme` value in the constructor and raises `ArgumentError`. ## How Has This Been Tested? Added tests in `test/mcp/annotations_test.rb`: - valid `priority` at the lower (0) and upper (1) bounds is accepted - out-of-range `priority` (above 1, below 0) raises `ArgumentError` The full unit suite and RuboCop pass locally. ## Breaking Changes Strictly speaking yes: a caller that currently passes an out-of-range `priority` (e.g. `priority: 99`) will now get an `ArgumentError`. Such values already violate the MCP specification, and `nil` / in-range values are unaffected. --- lib/mcp/annotations.rb | 2 ++ test/mcp/annotations_test.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/mcp/annotations.rb b/lib/mcp/annotations.rb index 9674c73..540588a 100644 --- a/lib/mcp/annotations.rb +++ b/lib/mcp/annotations.rb @@ -5,6 +5,8 @@ module MCP attr_reader :audience, :priority, :last_modified def initialize(audience: nil, priority: nil, last_modified: nil) + raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1) + @audience = audience @priority = priority @last_modified = last_modified diff --git a/test/mcp/annotations_test.rb b/test/mcp/annotations_test.rb index 60bfa72..a82b955 100644 --- a/test/mcp/annotations_test.rb +++ b/test/mcp/annotations_test.rb @@ -55,5 +55,31 @@ module MCP assert_equal({ lastModified: timestamp }, annotations.to_h) end + + def test_valid_priority_at_lower_bound + assert_nothing_raised do + Annotations.new(priority: 0) + end + end + + def test_valid_priority_at_upper_bound + assert_nothing_raised do + Annotations.new(priority: 1) + end + end + + def test_invalid_priority_above_upper_bound + exception = assert_raises(ArgumentError) do + Annotations.new(priority: 1.5) + end + assert_equal("The value of priority must be between 0 and 1.", exception.message) + end + + def test_invalid_priority_below_lower_bound + exception = assert_raises(ArgumentError) do + Annotations.new(priority: -0.1) + end + assert_equal("The value of priority must be between 0 and 1.", exception.message) + end end end From 2c295fe930688088aea5e905679fb49d405e3d12 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 16 Jun 2026 01:59:14 +0900 Subject: [PATCH 110/247] Document That Tool Arguments Arrive with Symbol Keys ## Motivation and Context The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key, at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream. Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments, and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path. A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then returns nil against a real client. Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API. This change therefore documents the existing contract rather than altering behavior. Closes #412 ## How Has This Been Tested? New regression tests in `test/mcp/server_test.rb`: - `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level, and string-key access on a nested value returns nil. - A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime. ## Breaking Changes None. This is a documentation and test change with no behavior change. The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling. ## Additional context Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and can be designed separately, since it is a behavior and dependency decision rather than a documentation fix. --- README.md | 4 ++++ docs/building-servers.md | 47 +++++++++++++++++++++++++++++++++++++++ lib/mcp/server.rb | 4 ++++ test/mcp/server_test.rb | 48 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/README.md b/README.md index 31faa24..dee86ca 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,10 @@ end The server_context parameter is the server_context passed into the server and can be used to pass per request information, e.g. around authentication state. +Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`. +Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`). +See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip. + ### Tool Annotations Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: diff --git a/docs/building-servers.md b/docs/building-servers.md index 0fd8d70..c133c70 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -180,6 +180,53 @@ server.define_tool( end ``` +### Tool argument keys + +Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects +and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, +so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. + +This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), +and nested objects must be read with symbol keys: + +```ruby +class ExampleTool < MCP::Tool + description "Echoes a nested argument" + input_schema( + properties: { + message: { type: "string" }, + payload: { + type: "object", + properties: { + subject: { type: "string" }, + } + } + }, + required: ["message"] + ) + + def self.call(message:, payload: nil, server_context:) + subject = payload && payload[:subject] # symbol key, not payload["subject"] + MCP::Tool::Response.new([{ + type: "text", + text: "Message: #{message}; subject: #{subject}" + }]) + end +end +``` + +Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: +Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. + +Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys +that a transport never delivers, so string-key access can pass tests yet fail against a real client. +Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: + +```ruby +delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) +MyTool.call(**delivered, server_context: nil) +``` + ## Prompts Prompts are templates for LLM interactions. Like tools, they can be defined in three ways: diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e734b7b..6530999 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -816,6 +816,10 @@ module MCP end def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil) + # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized + # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; + # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. + # See docs/building-servers.md ("Tool argument keys"). args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 43ece74..f9dfdef 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -426,6 +426,54 @@ module MCP assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end + test "#handle_json tools/call delivers nested object arguments with symbol keys at every level" do + received_payload = nil + server = Server.new(name: "test_server") + server.define_tool( + name: "nested_args_tool", + input_schema: { properties: { message: { type: "string" }, payload: { type: "object" } }, required: ["message"] }, + ) do |message:, payload: nil, server_context:| + received_payload = payload + Tool::Response.new([{ type: "text", text: "#{message} #{server_context.class}" }]) + end + + request_json = JSON.generate( + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "nested_args_tool", + arguments: { message: "hi", payload: { subject: "greet", nested: { deep: "value" } } }, + }, + ) + + server.handle_json(request_json) + + assert_equal({ subject: "greet", nested: { deep: "value" } }, received_payload) + assert_equal "greet", received_payload[:subject] + assert_nil received_payload["subject"] + end + + test "tool receives symbol keys when called under the JSON-round-tripped argument shape" do + received_payload = nil + tool = Tool.define( + name: "nested_args_tool", + input_schema: { properties: { payload: { type: "object" } } }, + ) do |payload: nil, server_context:| + received_payload = payload + Tool::Response.new([{ type: "text", text: server_context.class.to_s }]) + end + + # Round-trip the arguments through JSON the way a transport does, so the tool + # is exercised under the symbolized shape it actually receives at runtime. + arguments = { payload: { "subject" => "greet" } } + delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) + tool.call(**delivered, server_context: nil) + + assert_equal({ subject: "greet" }, received_payload) + assert_nil received_payload["subject"] + end + test "#handle tools/call returns tool execution error if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", From eb6ff5d80559d89c3d59851575cab341b542b54c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 17:05:02 +0900 Subject: [PATCH 111/247] Isolate Stateless Requests in Ephemeral Sessions per SEP-2567 ## Motivation and Context SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release) makes MCP sessionless: every Streamable HTTP POST must be fully self-contained, with no protocol-level session state shared between requests. `StreamableHTTPTransport` already implements the SEP's transport surface via `stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405, DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak: stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and `@client_capabilities` onto the shared `Server` instance. Concurrent stateless requests could therefore observe another client's identity, and the data persisted across requests, which is exactly what SEP-2567 forbids. The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack) solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture: - Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`), so client info, logging level, and initialized state live only for the duration of that POST. Repeated `initialize` requests are naturally permitted because each POST gets a fresh, never-initialized session. - `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising. With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message` would otherwise route every call into the exception reporter; non-delivery matches how these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises, as those are genuinely unsupported without a stream. Resolves #388. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: - a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched (the leak regression) - repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header - a tool calling `server_context.report_progress` under stateless mode returns its result normally and the exception reporter is never invoked - the existing "stateless mode does not support server-sent events" test is updated to assert the new `false` return instead of the removed raise All other existing stateless-mode tests pass unchanged. ## Breaking Changes `StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`. The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation, and the boolean return matches the method's documented semantics in every other non-delivery case. Default (session-oriented) mode is unchanged. --- README.md | 5 + .../transports/streamable_http_transport.rb | 46 ++++---- .../streamable_http_transport_test.rb | 101 ++++++++++++++++-- 3 files changed, 125 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index dee86ca..59e0d89 100644 --- a/README.md +++ b/README.md @@ -1664,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) ``` +In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required, +handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server), +and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped +(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. + You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 351d0a9..4b8f8ad 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -85,8 +85,10 @@ module MCP end def send_notification(method, params = nil, session_id: nil, related_request_id: nil) - # Stateless mode doesn't support notifications - raise "Stateless mode does not support notifications" if @stateless + # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising + # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from + # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. + return false if @stateless notification = { jsonrpc: "2.0", @@ -575,7 +577,9 @@ module MCP # `notifications/initialized`) through the server so it can update session state. def dispatch_notification(body_string, session_id) server_session = nil - if session_id && !@stateless + if @stateless + server_session = ephemeral_session + elsif session_id @mutex.synchronize do session = @sessions[session_id] server_session = session[:server_session] if session @@ -611,9 +615,10 @@ module MCP def handle_initialization(body_string, body) session_id = nil - server_session = nil - unless @stateless + if @stateless + server_session = ephemeral_session + else session_id = SecureRandom.uuid server_session = ServerSession.new(server: @server, transport: self, session_id: session_id) @@ -626,17 +631,13 @@ module MCP end end - response = if server_session - server_session.handle_json(body_string) - else - @server.handle_json(body_string) - end + response = server_session.handle_json(body_string) # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope), # `mark_initialized!` was never called. Discard the orphaned session and omit # the `Mcp-Session-Id` header so the client retries from a clean state instead of # reusing a never-initialized ID that would later look like a duplicate `initialize`. - if server_session && !server_session.initialized? + if session_id && !server_session.initialized? cleanup_session(session_id) session_id = nil end @@ -657,15 +658,15 @@ module MCP def handle_regular_request(body_string, session_id, related_request_id: nil) server_session = nil - unless @stateless - if session_id - error_response = validate_and_touch_session(session_id) - return error_response if error_response + if @stateless + server_session = ephemeral_session + elsif session_id + error_response = validate_and_touch_session(session_id) + return error_response if error_response - @mutex.synchronize do - session = @sessions[session_id] - server_session = session[:server_session] if session - end + @mutex.synchronize do + session = @sessions[session_id] + server_session = session[:server_session] if session end end @@ -775,6 +776,13 @@ module MCP @mutex.synchronize { @sessions.key?(session_id) } end + # Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession` + # so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567 + def ephemeral_session + ServerSession.new(server: @server, transport: self, session_id: nil) + end + # Returns true iff a session exists and is not past its idle timeout. Expired sessions # are evicted as a side effect so a live request never observes a zombie session that # the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index bf3d0b2..550e926 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2061,17 +2061,102 @@ module MCP end test "stateless mode does not support server-sent events" do + # Notifications have no stream to ride in stateless mode; the transport reports non-delivery + # instead of raising so per-request session notify_* helpers degrade gracefully (SEP-2567). stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) - e = assert_raises(RuntimeError) do - stateless_transport.send_notification( - "test_notification", - { message: "Hello" }, - session_id: "some_session_id", - ) - end + result = stateless_transport.send_notification( + "test_notification", + { message: "Hello" }, + session_id: "some_session_id", + ) - assert_equal("Stateless mode does not support notifications", e.message) + refute result + end + + test "stateless mode does not leak client info onto the shared server" do + # Each stateless POST runs against an ephemeral per-request session (SEP-2567); concurrent requests + # must never observe another client's identity through the shared Server instance. + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "client-a", version: "1.0" }, + }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + assert_nil @server.client_capabilities + assert_nil @server.instance_variable_get(:@client) + end + + test "stateless mode allows repeated initialize requests" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + 2.times do |i| + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: i + 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "client-#{i}", version: "1.0" }, + }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert body.key?("result"), "initialize ##{i + 1} should succeed, got #{body.inspect}" + refute response[1].key?("Mcp-Session-Id") + end + end + + test "stateless mode skips progress notifications without raising" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(exception, _context) { reported << exception } + + server = Server.new(name: "stateless_progress_test", configuration: configuration) + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + stateless_transport = StreamableHTTPTransport.new(server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "tok" } }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal "ok", body.dig("result", "content", 0, "text") + assert_empty reported end test "stateless mode responds with 202 when client sends a notification/initialized request" do From 2c223dd2f0e41e221cae2942546068ebd6c425bc Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 12 Jun 2026 23:48:20 +0900 Subject: [PATCH 112/247] Fall Back to Legacy 2025-03-26 OAuth Discovery for Servers Without PRM ## Motivation and Context The MCP 2025-03-26 authorization spec predates Protected Resource Metadata: the MCP server itself acted as the authorization base URL, its RFC 8414 metadata was fetched from the server origin, and the spec's "Fallbacks for Servers without Metadata Discovery" section required clients to use the default endpoints `/authorize`, `/token`, and `/register` relative to the authorization base URL when no metadata was published. Both the TypeScript SDK (`discoverOAuthServerInfo` falls back to the server base URL on PRM failure; `startAuthorization` / `executeTokenRequest` / `registerClient` default the endpoint paths) and the Python SDK (`build_oauth_authorization_server_metadata_discovery_urls(None, ...)` plus the same endpoint defaults) keep this as client-side backwards compatibility, and the `auth/2025-03-26-oauth-metadata-backcompat` and `auth/2025-03-26-oauth-endpoint-fallback` conformance scenarios exercise it. The Ruby flow previously raised as soon as PRM discovery failed, so both scenarios were expected conformance failures. This change adds the same fallback: - `Flow#locate_authorization_server` first attempts PRM discovery; on any discovery failure (404s, network errors, malformed documents, matching the TypeScript and Python SDKs' broad fallback) the MCP server's origin becomes the legacy authorization base URL. The Communication Security check (HTTPS or loopback) still applies to that origin, and PRM documents that parse correctly keep their strict shape validation. - `Flow#authorization_server_metadata` fetches RFC 8414 metadata from the base URL. On the legacy path the `issuer` byte-match is skipped: the 2025-03-26 spec predates that requirement, and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from the discovery origin (neither reference SDK validates the issuer on this path). On the modern path the byte-match is unchanged. - When even the metadata document is absent, synthetic metadata carrying the legacy spec's default endpoints is used, with PKCE S256 assumed (the legacy spec mandates PKCE; the TypeScript and Python SDKs hardcode S256 on this path too). Endpoint HTTPS checks still apply. - `run!` and `refresh!` share the new discovery helpers, and both 2025-03-26 scenarios are removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - the metadata-backcompat shape: no PRM, AS metadata at the server origin with `/oauth`-prefixed endpoints and a mismatched `issuer` completes the flow against those endpoints (no issuer error) - the endpoint-fallback shape: no metadata at all registers, authorizes, and exchanges the code at `/register`, `/authorize`, and `/token` on the origin, still sending `code_challenge` (S256) and `code_verifier` - a remote plain-http origin is rejected as the legacy authorization base - strict-mode regression: with PRM present, a mismatched `issuer` still aborts the flow - a malformed PRM (top-level JSON array) now selects the legacy path instead of leaking a `TypeError`, and surfaces a domain error when that path also dead-ends Two existing tests that asserted PRM failure was terminal were updated to dead-end the legacy path explicitly, preserving their original intent (single flow attempt, domain error type). ## Breaking Changes None for spec-compliant 2025-06-18+ servers, whose PRM-based discovery and issuer validation are unchanged. Servers that previously failed hard during PRM discovery now get one legacy discovery attempt before the flow errors, which can only turn previously failing flows into working ones. --- README.md | 3 + conformance/expected_failures.yml | 2 - lib/mcp/client/oauth/flow.rb | 103 ++++++++++++--- test/mcp/client/oauth/flow_test.rb | 154 ++++++++++++++++++++++- test/mcp/client/oauth/http_oauth_test.rb | 5 + 5 files changed, 246 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 31faa24..1b7460d 100644 --- a/README.md +++ b/README.md @@ -1986,6 +1986,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - Send `Authorization: Bearer ` on every request when a token is available. - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. +- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts + as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), + and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 01b6406..1ab4cc4 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -5,7 +5,5 @@ client: # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/2025-03-26-oauth-metadata-backcompat - - auth/2025-03-26-oauth-endpoint-fallback - auth/client-credentials-jwt - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index ed76ee5..396188e 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -38,22 +38,18 @@ module MCP ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") end - prm = fetch_protected_resource_metadata( + prm, authorization_server = locate_authorization_server( server_url: server_url, resource_metadata_url: resource_metadata_url, ) - authorization_server = first_authorization_server(prm) - ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on # both the authorization and token requests. When PRM advertises a `resource`, # it MUST identify the same MCP server we are talking to; otherwise we are # being redirected to credentials minted for a different audience. - resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) + resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server) - ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) - ensure_secure_endpoints!(as_metadata) + as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) if provider_authorization_flow == :client_credentials return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) @@ -63,7 +59,7 @@ module MCP client_info = ensure_client_registered(as_metadata: as_metadata) - effective_scope = resolve_scope(scope: scope, prm: prm) + effective_scope = resolve_scope(scope: scope, prm: prm || {}) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) @@ -158,18 +154,14 @@ module MCP ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") end - prm = fetch_protected_resource_metadata( + prm, authorization_server = locate_authorization_server( server_url: server_url, resource_metadata_url: resource_metadata_url, ) - authorization_server = first_authorization_server(prm) - ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") - resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) + resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server) - ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"]) - ensure_secure_endpoints!(as_metadata) + as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, @@ -221,6 +213,87 @@ module MCP fetch_metadata_json(urls, label: "protected resource metadata") end + # Locates the authorization server for `server_url` and returns `[prm, authorization_server]`. + # + # Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in + # `authorization_servers`. + # + # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil + # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs. + # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior. + # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery + def locate_authorization_server(server_url:, resource_metadata_url:) + prm = begin + fetch_protected_resource_metadata( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + rescue AuthorizationError + nil + end + + if prm + authorization_server = first_authorization_server(prm) + ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") + [prm, authorization_server] + else + authorization_base = server_origin!(server_url) + ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)") + [nil, authorization_base] + end + end + + # Fetches and validates the authorization server's RFC 8414 metadata. + # + # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3). + # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement, + # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from + # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path). + # When even the metadata document is absent, the legacy spec's default endpoints are used. + def authorization_server_metadata(authorization_server:, legacy:) + metadata = if legacy + begin + fetch_authorization_server_metadata(issuer_url: authorization_server) + rescue AuthorizationError + default_legacy_metadata(authorization_server) + end + else + fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched| + ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"]) + end + end + + ensure_secure_endpoints!(metadata) + metadata + end + + # The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths + # relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata + # to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too). + def default_legacy_metadata(authorization_base) + { + "issuer" => authorization_base, + "authorization_endpoint" => "#{authorization_base}/authorize", + "token_endpoint" => "#{authorization_base}/token", + "registration_endpoint" => "#{authorization_base}/register", + "code_challenge_methods_supported" => ["S256"], + } + end + + # Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM. + def server_origin!(server_url) + uri = URI.parse(server_url.to_s) + unless uri.is_a?(URI::HTTP) && uri.host + raise AuthorizationError, + "Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}." + end + + port_part = uri.port == uri.default_port ? "" : ":#{uri.port}" + "#{uri.scheme}://#{uri.host}#{port_part}" + rescue URI::InvalidURIError => e + raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}." + end + def fetch_authorization_server_metadata(issuer_url:) urls = Discovery.authorization_server_metadata_urls(issuer_url) fetch_metadata_json(urls, label: "authorization server metadata") diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index d36c5e9..672e889 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -544,14 +544,20 @@ module MCP assert_match(/PRM `resource`|not a valid URI/i, error.message) end - def test_run_raises_when_prm_is_not_a_json_object - # Valid JSON but the wrong shape: indexing into a top-level array - # would otherwise raise `TypeError` and leak out of the SDK. + def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object + # Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path + # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing + # a domain error rather than a raw `TypeError` from indexing the array. stub_request(:get, @prm_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: "[]", ) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return(status: 404) provider = Provider.new( client_metadata: { @@ -569,7 +575,147 @@ module MCP Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) end - assert_match(/not a JSON object/i, error.message) + assert_match(/Dynamic client registration failed/i, error.message) + assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + end + + # Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert + # which endpoint was used. + def build_legacy_discovery_provider(holder) + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + holder[:authorization_url] = url + holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", holder[:state]] }, + ) + end + + def stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + end + + def test_run_falls_back_to_server_origin_metadata_without_prm + # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin, + # OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin. + # The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it). + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://srv.example.com/oauth", + authorization_endpoint: "https://srv.example.com/oauth/authorize", + token_endpoint: "https://srv.example.com/oauth/token", + registration_endpoint: "https://srv.example.com/oauth/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "https://srv.example.com/oauth/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/oauth/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("legacy-token", provider.access_token) + assert_equal("/oauth/authorize", holder[:authorization_url].path) + assert_requested(:post, "https://srv.example.com/oauth/register") + assert_requested(:post, "https://srv.example.com/oauth/token") + end + + def test_run_falls_back_to_default_endpoints_without_any_metadata + # Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata, + # the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256. + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("/authorize", holder[:authorization_url].path) + query = URI.decode_www_form(holder[:authorization_url].query).to_h + assert_equal("S256", query["code_challenge_method"]) + refute_empty(query["code_challenge"].to_s) + assert_requested(:post, "https://srv.example.com/register") + assert_requested(:post, "https://srv.example.com/token") do |req| + URI.decode_www_form(req.body).to_h["code_verifier"].to_s != "" + end + end + + def test_run_legacy_fallback_rejects_insecure_authorization_base + # The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not + # become the authorization base URL. + stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: "http://internal.example.com/mcp") + end + + assert_match(/legacy authorization base URL/, error.message) + end + + def test_run_keeps_strict_issuer_validation_when_prm_is_present + # The legacy issuer-check relaxation must not leak into the modern path: with PRM present, + # a mismatched issuer still aborts. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://evil.example.com", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + ), + ) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/`issuer` does not match/, error.message) end def test_run_raises_when_prm_authorization_servers_is_not_an_array diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index c22ad86..26d074d 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -1304,6 +1304,11 @@ module MCP body: "", ) + # With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once. + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return(status: 404) + provider = Provider.new( client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, redirect_uri: "http://localhost:0/callback", From cfc9a699d6b83f54e420465b0045d747cecc7c00 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 18 Jun 2026 09:05:42 +0900 Subject: [PATCH 113/247] Fix a build error in the Ruby 3.1 CI matrix ## Motivation and Context This resolves the following build failure that occurs when using Ruby 3.1 with I18n 1.15.0: ```console $ bundle exec rake (snip) /Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:58: in `config': undefined method `[]' for Fiber:Class (NoMethodError) current = Fiber[:i18n_config] || self.config = I18n::Config.new ^^^^^^^^^^^^^^ from /Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:88:in `load_path' ``` https://github.com/modelcontextprotocol/ruby-sdk/actions/runs/27724973112/job/82025136923 According to the report in https://github.com/ruby-i18n/i18n/issues/735, this issue has been fixed in I18n 1.15.1 and later. However, the already released I18n 1.15.0 still triggers the build error. While this could potentially be addressed in a better way on the I18n side, this change updates the Gemfile to avoid using I18n 1.15.0, which is known to cause the issue. ## How Has This Been Tested? The build error described above has been resolved. ## Breaking Changes None. --- Gemfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index c9f42cb..0aa50ee 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,8 @@ gem "rackup", ">= 2.1.0" gem "activesupport" # Fix io-console install error when Ruby 3.0. gem "debug" if RUBY_VERSION >= "3.1" +# Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735). +gem "i18n", "!= 1.15.0" gem "rake", "~> 13.0" gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0" gem "yard", "~> 0.9" From 5ca8b6ebbb2dedaa8c69e521b1b45ea0798afd1e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 19 Jun 2026 09:42:57 +0900 Subject: [PATCH 114/247] Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106 ## Motivation and Context SEP-2106 (modelcontextprotocol/modelcontextprotocol#2106, merged for the 2026-07-28 spec release) makes tool `inputSchema` and `outputSchema` conform to the full JSON Schema 2020-12 vocabulary: an input schema keeps `type: "object"` at the root but may use any 2020-12 keyword below it; an output schema may be ANY valid schema (object, array, primitive, or a root-level composition); and `CallToolResult.structuredContent` widens from an object to any JSON value. The SEP also adds resource bounds: `$ref` resolution is restricted (same-document only in the reference implementation) and composition-heavy documents must be bounded to avoid excessive validation cost. This follows the TypeScript SDK's reference implementation (typescript-sdk#2249; the Python SDK tracks the same work in python-sdk#2792): - `Tool::Schema` now validates against the JSON Schema 2020-12 metaschema rather than the draft-04 metaschema. The draft-04 pin was a stopgap from when the SDK used the `json-schema` gem, which did not support 2020-12; `json_schemer` does, so `$defs`/`$ref` and the rest of the 2020-12 vocabulary now resolve natively. This matches the dialect the SDK already advertises in emitted schemas and the Python SDK's behavior, whose `jsonschema.validate` selects the validator from the schema's `$schema`. - `Tool::Schema` moves root-type defaulting into an overridable `apply_default_root_type!` hook. `InputSchema` keeps the historical `type: "object"` default; `OutputSchema` now applies it only when no root schema keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`) is present. The previous unconditional default merged `type: "object"` into root combinators such as `{ oneOf: [...] }`, producing a wrong schema, so that case is a bug fix. - `Tool::Schema` enforces the TypeScript SDK's schema bounds at construction time: only same-document `$ref`/`$dynamicRef`s (starting with `#`, so schema handling can never trigger network or file access), `MAX_SCHEMA_DEPTH = 64` nesting levels, and `MAX_SUBSCHEMA_COUNT = 10_000` subschema objects, all raising `ArgumentError` on violation. - `Server#call_tool` mirrors non-object `structuredContent` into `content` as serialized JSON text when the tool provided no content blocks, so pre-SEP clients that only read `content` still receive the data. Object results and explicit content are untouched. Resolves #377. ## How Has This Been Tested? - `test/mcp/tool/output_schema_test.rb`: root-level `oneOf`, `$ref`+`$defs`, primitive, and `enum` schemas serialize without an injected `type` and validate results correctly; the `properties`-only shorthand still serializes with `type: "object"` (wire-format regression); explicit `type: "array"` keeps working. - `test/mcp/tool/input_schema_test.rb`: an input schema using `$defs`, `$ref`, `oneOf`, `if`/`then`, and `allOf` keeps its object root and round-trips all keywords; a draft-04-only boolean `exclusiveMinimum` is rejected under the 2020-12 dialect while the numeric form is accepted. - `test/mcp/tool/schema_test.rb`: depth and subschema-count bound violations raise `ArgumentError`; non-same-document `$ref`s (remote URI, sibling file) are rejected while `#/$defs/...` is accepted. The previous unbounded-depth caching test is replaced, since the depth bound now rejects such documents by design. - `test/mcp/server_test.rb`: `tools/call` with array `structuredContent` and no content gains the serialized TextContent fallback; explicit content is not overwritten; object `structuredContent` gets no fallback. `bundle exec rake` (tests, RuboCop, and conformance baseline, including the `json-schema-2020-12` server scenario) passes. ## Breaking Changes Three narrow behavior changes, all intentional per the SEP: - Runtime validation now uses the JSON Schema 2020-12 metaschema instead of draft-04. Schemas that rely on draft-04-only syntax are rejected at construction time. The practical case is the boolean `exclusiveMinimum`/`exclusiveMaximum` form (deprecated since draft-06), which must now be the numeric form; the Python SDK rejects it the same way. Other draft-04 spellings (`definitions`, `id`, `dependencies`) still validate, since 2020-12 tolerates unknown keywords. - Schemas that exceed the new resource bounds (nesting deeper than 64, more than 10,000 subschema objects) or use a non-same-document `$ref`/`$dynamicRef` now raise `ArgumentError` at construction time. Previously such documents were accepted (external references were already never fetched, only ignored). - An `OutputSchema` whose root declares a schema keyword other than `type` (e.g. `oneOf`) no longer has `type: "object"` merged into it. The old output was an invalid hybrid schema, so no conforming consumer could have relied on it. --- README.md | 28 ++++++++++- lib/mcp/server.rb | 14 +++++- lib/mcp/tool/output_schema.rb | 17 +++++++ lib/mcp/tool/schema.rb | 74 +++++++++++++++++++++++++---- test/mcp/server_test.rb | 52 ++++++++++++++++++++ test/mcp/tool/input_schema_test.rb | 36 ++++++++++++++ test/mcp/tool/output_schema_test.rb | 45 ++++++++++++++++++ test/mcp/tool/schema_test.rb | 62 ++++++++++++++++++------ 8 files changed, 303 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index d316da5..b92daff 100644 --- a/README.md +++ b/README.md @@ -698,8 +698,28 @@ class WeatherTool < MCP::Tool end ``` -Please note: in this case, you must provide `type: "array"`. The default type -for output schemas is `object`. +Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`, +applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`). + +Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root +(`{ type: "string" }`) or a root-level composition: + +```ruby +class FlexibleTool < MCP::Tool + output_schema( + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "number" } } + ] + ) +end +``` + +Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it +(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to +all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are +capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects; +violations raise `ArgumentError` at construction time. MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: @@ -729,6 +749,10 @@ Tools can return structured data alongside text content using the `structured_co The structured content will be included in the JSON-RPC response as the `structuredContent` field. +Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array) +without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients +that only read `content` still receive the data. + ```ruby class WeatherTool < MCP::Tool description "Get current weather and return structured data" diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 6530999..312de4b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -636,7 +636,7 @@ module MCP tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation ) validate_tool_call_result!(tool, result) - result + serialize_structured_content_fallback(result) rescue RequestHandlerError, CancelledError # CancelledError is intentionally not wrapped so `handle_request` can turn it into # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. @@ -801,6 +801,18 @@ module MCP tool.output_schema.validate_result(result[:structuredContent]) end + # Per SEP-2106, `structuredContent` may be any JSON value, not only an object. + # Clients on older protocol versions may only read `content`, + # so when a tool returns non-object structured content without providing + # any content blocks, mirror the value into `content` as serialized JSON text. + def serialize_structured_content_fallback(result) + structured = result[:structuredContent] + return result if structured.nil? || structured.is_a?(Hash) + return result unless result[:content].nil? || result[:content].empty? + + result.merge(content: [{ type: "text", text: JSON.generate(structured) }]) + end + # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`. diff --git a/lib/mcp/tool/output_schema.rb b/lib/mcp/tool/output_schema.rb index 8bf7ed9..1d6e2a5 100644 --- a/lib/mcp/tool/output_schema.rb +++ b/lib/mcp/tool/output_schema.rb @@ -7,12 +7,29 @@ module MCP class OutputSchema < Schema class ValidationError < StandardError; end + # Root-level keywords whose presence means the user already chose a root schema shape, + # so no `type: "object"` default should be merged in. + ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze + def validate_result(result) errors = fully_validate(result) if errors.any? raise ValidationError, "Invalid result: #{errors.join(", ")}" end end + + private + + # Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive, + # or a root-level composition. + # Default the root to an object only when no root schema keyword is present, which preserves the wire output + # of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched + # (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema). + def apply_default_root_type! + return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) } + + super + end end end end diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb index e71a8d8..6811d20 100644 --- a/lib/mcp/tool/schema.rb +++ b/lib/mcp/tool/schema.rb @@ -36,16 +36,29 @@ module MCP end VALIDATION_CACHE = ValidationCache.new - # JSON Schema 2020-12 is the default dialect for MCP schema definitions - # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation - # is still performed against the JSON Schema draft-04 metaschema. + # JSON Schema 2020-12 is the default dialect for MCP schema definitions per MCP 2025-11-25 (SEP-1613), + # and SEP-2106 requires tool schemas to conform to the full 2020-12 vocabulary. Both emission and + # runtime validation use this dialect. Because MCP mandates 2020-12, the SDK validates against it + # regardless of any `$schema` a document embeds; for compliant schemas this is the same dialect + # the Python SDK's `jsonschema.validate` resolves to. JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema" - DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#" + # Resource bounds for schema compilation, mirroring the TypeScript SDK's schema bounds (SEP-2106): + # schemas may use the full JSON Schema 2020-12 vocabulary including composition keywords and `$ref`, + # so adversarial documents must be rejected before they can cause excessive validation cost. + # Only same-document references (starting with `#`) are accepted, so schema handling can never trigger network + # or file access. + MAX_SCHEMA_DEPTH = 64 + MAX_SUBSCHEMA_COUNT = 10_000 + + # Reference keywords whose targets the SDK refuses to dereference. Both `$ref` and `$dynamicRef` may carry + # an absolute URI under JSON Schema 2020-12, so a non-same-document value is an external reference. + REFERENCE_KEYWORDS = [:"$ref", :"$dynamicRef"].freeze def initialize(schema = {}) @schema = JSON.parse(JSON.dump(schema), symbolize_names: true) - @schema[:type] ||= "object" + apply_default_root_type! + validate_schema_bounds! validate_schema! end @@ -61,6 +74,48 @@ module MCP private + # Root-type defaulting hook. The base class preserves the historical behavior of defaulting the root + # to an object schema; `OutputSchema` overrides this because SEP-2106 allows any root schema there. + def apply_default_root_type! + @schema[:type] ||= "object" + end + + # Enforces `MAX_SCHEMA_DEPTH` / `MAX_SUBSCHEMA_COUNT` and the same-document reference rule over + # the whole schema document. + def validate_schema_bounds! + subschema_count = 0 + stack = [[@schema, 1]] + + until stack.empty? + node, depth = stack.pop + if depth > MAX_SCHEMA_DEPTH + raise ArgumentError, + "Invalid JSON Schema: nesting exceeds the maximum depth of #{MAX_SCHEMA_DEPTH}." + end + + case node + when Hash + subschema_count += 1 + if subschema_count > MAX_SUBSCHEMA_COUNT + raise ArgumentError, + "Invalid JSON Schema: document exceeds the maximum of #{MAX_SUBSCHEMA_COUNT} subschema objects." + end + + REFERENCE_KEYWORDS.each do |keyword| + ref = node[keyword] + next unless ref.is_a?(String) && !ref.start_with?("#") + + raise ArgumentError, + "Invalid JSON Schema: only same-document #{keyword} (starting with '#') is supported, got #{ref.inspect}." + end + + node.each_value { |child| stack << [child, depth + 1] } + when Array + node.each { |child| stack << [child, depth + 1] } + end + end + end + def stringify(obj) case obj when Hash @@ -78,13 +133,16 @@ module MCP # Memoized per Schema instance because schema content is fixed at construction, # so the compiled schemer is reusable across many `fully_validate` calls. # + # Validated against the JSON Schema 2020-12 metaschema per SEP-2106, so `$defs`/`$ref` and + # the rest of the 2020-12 vocabulary resolve natively. + # # `format: false` preserves the legacy behavior of the previous `json-schema` based implementation, # which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as # `ArgumentError` so callers see the same exception class they used to. def schemer @schemer ||= JSONSchemer.schema( stringify(schema_for_validation), - meta_schema: DRAFT4_META_SCHEMA_URI, + meta_schema: JSON_SCHEMA_2020_12_URI, format: false, ) rescue RegexpError => e @@ -112,8 +170,8 @@ module MCP VALIDATION_CACHE.store(key) end - # `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation: - # this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas. + # Strip the top-level `$schema` before validation so the SDK always validates against + # the 2020-12 metaschema (SEP-2106) regardless of any dialect URI a caller embedded in the document. def schema_for_validation return @schema unless @schema.key?(:"$schema") diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index f9dfdef..22eed19 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2884,6 +2884,58 @@ module MCP end end + test "#handle tools/call mirrors non-object structuredContent into serialized text content" do + # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, + # so the server adds a serialized fallback when the tool provided no content blocks. + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new(nil, structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + end + + test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new([{ type: "text", text: "two items" }], structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [{ type: "text", text: "two items" }], response.dig(:result, :content) + end + + test "#handle tools/call leaves object structuredContent without a text fallback" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new(nil, structured_content: { answer: 42 }) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal({ answer: 42 }, response.dig(:result, :structuredContent)) + assert_empty response.dig(:result, :content) + end + test "#handle tools/list returns paginated results when page_size is set" do tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 972c843..6b239eb 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -101,6 +101,22 @@ module MCP end end + test "rejects a draft-04-only boolean exclusiveMinimum under the 2020-12 dialect" do + # SEP-2106 validates tool schemas against the JSON Schema 2020-12 metaschema, + # where `exclusiveMinimum` must be a number. The draft-04 boolean form (deprecated since draft-06) + # is rejected at construction, matching the Python SDK's `jsonschema` validator selection. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { age: { type: "integer", minimum: 0, exclusiveMinimum: true } }) + end + assert_includes error.message, "Invalid JSON Schema" + end + + test "accepts the 2020-12 numeric exclusiveMinimum form" do + assert_nothing_raised do + InputSchema.new(properties: { age: { type: "integer", exclusiveMinimum: 0 } }) + end + end + test "schema without required arguments is valid" do assert_nothing_raised do InputSchema.new(properties: { foo: { type: "string" } }) @@ -183,6 +199,26 @@ module MCP assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref] end + test "keeps the object root type while accepting 2020-12 composition keywords" do + # Per SEP-2106, an input schema root must stay `type: "object"` but may use + # the full 2020-12 vocabulary below the root. + schema = InputSchema.new( + "$defs": { name: { type: "string", minLength: 1 } }, + properties: { + name: { "$ref": "#/$defs/name" }, + value: { oneOf: [{ type: "string" }, { type: "integer" }] }, + }, + if: { properties: { value: { type: "integer" } } }, + then: { required: ["name"] }, + allOf: [{ properties: { extra: { type: "boolean" } } }], + ) + + assert_equal "object", schema.to_h[:type] + assert schema.to_h.key?(:"$defs") + assert schema.to_h.key?(:if) + assert schema.to_h.key?(:allOf) + end + test "== compares two input schemas with the same properties, required fields" do schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) diff --git a/test/mcp/tool/output_schema_test.rb b/test/mcp/tool/output_schema_test.rb index 5403f00..a9522cc 100644 --- a/test/mcp/tool/output_schema_test.rb +++ b/test/mcp/tool/output_schema_test.rb @@ -215,6 +215,51 @@ module MCP end end + test "does not inject a root type into a root-level oneOf schema" do + # Per SEP-2106, an output schema may be any JSON Schema 2020-12 document. + # Merging `type: "object"` into a root combinator would produce a wrong schema. + schema = OutputSchema.new(oneOf: [{ type: "string" }, { type: "integer" }]) + + refute schema.to_h.key?(:type) + assert_equal [{ type: "string" }, { type: "integer" }], schema.to_h[:oneOf] + assert_nothing_raised { schema.validate_result("text") } + assert_nothing_raised { schema.validate_result(42) } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(1.5) } + end + + test "does not inject a root type into a root-level $ref schema" do + schema = OutputSchema.new( + "$ref": "#/$defs/result", + "$defs": { result: { type: "string" } }, + ) + + refute schema.to_h.key?(:type) + assert_nothing_raised { schema.validate_result("text") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) } + end + + test "allows primitive root schemas" do + schema = OutputSchema.new(type: "string") + + assert_nothing_raised { schema.validate_result("text") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) } + end + + test "does not inject a root type into a root-level enum schema" do + schema = OutputSchema.new(enum: ["red", "green", "blue"]) + + refute schema.to_h.key?(:type) + assert_nothing_raised { schema.validate_result("red") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result("yellow") } + end + + test "defaults a properties-only schema to a root object" do + # Wire-format regression: the common shorthand keeps serializing with the injected `type: "object"`. + schema = OutputSchema.new(properties: { result: { type: "string" } }) + + assert_equal "object", schema.to_h[:type] + end + test "allow to declare array schemas" do schema = OutputSchema.new({ type: "array", diff --git a/test/mcp/tool/schema_test.rb b/test/mcp/tool/schema_test.rb index edc90e3..cb3fd7a 100644 --- a/test/mcp/tool/schema_test.rb +++ b/test/mcp/tool/schema_test.rb @@ -51,24 +51,58 @@ module MCP assert_raises(ArgumentError) { InputSchema.new(invalid) } end - test "a schema at the normalization depth limit is cached without a nesting error" do - # The deepest schema the initializer can still normalize via JSON.dump/parse. - # The cache key must tolerate the same depth; the default JSON.generate - # nesting limit (100) is stricter than normalization and would raise here. - schema = { properties: { leaf: { type: "string" } } } - loop do - candidate = { properties: { child: schema } } - JSON.parse(JSON.dump(candidate)) - schema = candidate - rescue JSON::NestingError - break + test "a schema nested deeper than MAX_SCHEMA_DEPTH raises" do + # SEP-2106 resource bounds: unbounded nesting would make downstream validation arbitrarily expensive. + # Each wrapping adds two levels (the schema hash and its `properties` hash), so this is the smallest + # nesting that exceeds MAX_SCHEMA_DEPTH. + wrappings = Schema::MAX_SCHEMA_DEPTH / 2 + 1 + schema = { type: "string" } + wrappings.times do + schema = { type: "object", properties: { child: schema } } end - JSONSchemer::Schema.any_instance.stubs(:validate_schema).returns([]) + error = assert_raises(ArgumentError) { InputSchema.new(schema) } + assert_match(/maximum depth/, error.message) + end + test "a schema with more subschema objects than MAX_SUBSCHEMA_COUNT raises" do + properties = {} + (Schema::MAX_SUBSCHEMA_COUNT + 1).times do |i| + properties[:"property_#{i}"] = { type: "string" } + end + + error = assert_raises(ArgumentError) { InputSchema.new(properties: properties) } + assert_match(/subschema/, error.message) + end + + test "rejects a $ref pointing outside the schema document" do + # SEP-2106 requires same-document `$ref` resolution only; a remote URI or a sibling file must never trigger network + # or file access. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$ref": "https://example.com/schema.json#/defs/bar" } }) + end + assert_match(/same-document/, error.message) + + assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$ref": "other.json#/defs/bar" } }) + end + end + + test "rejects a $dynamicRef pointing outside the schema document" do + # 2020-12 allows `$dynamicRef` to carry an absolute URI too, so the same-document restriction must cover it as well as `$ref`. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$dynamicRef": "https://example.com/schema.json#meta" } }) + end + assert_match(/same-document/, error.message) + assert_match(/\$dynamicRef/, error.message) + end + + test "accepts a same-document $ref" do assert_nothing_raised do - InputSchema.new(schema) - InputSchema.new(schema) + InputSchema.new( + "$defs": { bar: { type: "string" } }, + properties: { foo: { "$ref": "#/$defs/bar" } }, + ) end end From 49a08bf3be017e188d2b1fba88cc5342d821c75d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 20 Jun 2026 00:58:36 +0900 Subject: [PATCH 115/247] Release 0.21.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 13 +++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c020db6..f21612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.0] - 2026-06-20 + +### Added + +- Support capability extensions per SEP-2133 (#405) +- Add range validation for `MCP::Annotations#priority` per MCP specification (#410) +- Isolate stateless requests in ephemeral sessions per SEP-2567 (#415) + +### Changed + +- Set OIDC `application_type` on Dynamic Client Registration per SEP-837 (#408) +- Fall back to legacy 2025-03-26 OAuth discovery for servers without PRM (#414) + ## [0.20.0] - 2026-06-14 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index b885c8e..2eddb8a 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.20.0" + VERSION = "0.21.0" end From c4c73e05f522695b14b44471a7869afd46850226 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 19 Jun 2026 21:17:52 +0900 Subject: [PATCH 116/247] Fix a `SyntaxError` on Ruby 2.7.0 caused by arguments forwarding syntax ## Motivation and Context The gemspec already declares `required_ruby_version >= 2.7.0`, but `ServerContext` used the `...` arguments forwarding syntax in `method_missing`. That syntax was added in Ruby 2.7.3, so requiring the gem raised a `SyntaxError` on Ruby 2.7.0 through 2.7.2, and the library could not be loaded at all. RuboCop did not catch this because its Parser backend runs on Ruby 2.7.8, where the syntax is valid. CI missed it too because the test matrix tested `2.7`, which resolves to the latest 2.7.x rather than the declared minimum. ## How Has This Been Tested? Forward arguments explicitly with `*args, **kwargs, &block` so the method loads on Ruby 2.7.0, and pin the CI test matrix to 2.7.0 so the suite runs against the declared minimum instead of the latest 2.7.x. Running the suite on 2.7.0 also requires a `Hash.ruby2_keywords_hash?` shim in the test helper, since mocha would otherwise raise a `NoMethodError` on that version. ## Breaking Changes None. Closes #418 --- .github/workflows/ci.yml | 2 +- lib/mcp/server_context.rb | 9 +++++++-- test/mcp/server_context_test.rb | 14 ++++++++++++++ test/test_helper.rb | 10 ++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9077935..6610745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: entry: - - { ruby: '2.7', allowed-failure: false } + - { ruby: '2.7.0', allowed-failure: false } # Minimum supported Ruby version. - { ruby: '3.0', allowed-failure: false } - { ruby: '3.1', allowed-failure: false } - { ruby: '3.2', allowed-failure: false } diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index baed180..9873984 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -130,9 +130,14 @@ module MCP end end - def method_missing(name, ...) + # Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax. + # The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8, + # so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it + # raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps + # this method loadable on Ruby 2.7.0. + def method_missing(name, *args, **kwargs, &block) if @context.respond_to?(name) - @context.public_send(name, ...) + @context.public_send(name, *args, **kwargs, &block) else super end diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 86fd972..44fa12e 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -201,6 +201,20 @@ module MCP assert_equal "custom_value", server_context.custom_method end + test "ServerContext forwards positional, keyword, and block arguments to the context" do + context = Object.new + def context.combine(prefix, suffix:, &block) + block.call("#{prefix}-#{suffix}") + end + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + result = server_context.combine("a", suffix: "b", &:upcase) + + assert_equal "A-B", result + end + test "ServerContext#report_progress works with nil context" do progress = mock progress.expects(:report).with(50, total: 100, message: nil).once diff --git a/test/test_helper.rb b/test/test_helper.rb index 19e9974..b89bffa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,6 +6,16 @@ require "minitest/autorun" require "minitest/mock" require "mocha/minitest" +# mocha relies on `Hash.ruby2_keywords_hash?`, which is absent on Ruby 2.7.0 and the earlier 2.7.x releases +# before it was backported. Those versions also lack the flag-setting APIs, so no hash is ever flagged as +# ruby2_keywords and returning `false` is correct. Without this shim, mocha raises `NoMethodError` and +# the suite cannot run on Ruby 2.7.0. +unless Hash.respond_to?(:ruby2_keywords_hash?) + def Hash.ruby2_keywords_hash?(_hash) + false + end +end + require "active_support" require "active_support/test_case" From 4c3268ccb1b47e7360192f5802d051638686cec1 Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Mon, 22 Jun 2026 00:55:28 +0900 Subject: [PATCH 117/247] Add `audience` role validation for `MCP::Annotations` per MCP specification The MCP specification constrains `Annotations.audience` to an array of `Role` values (`"user"` or `"assistant"`), but the Ruby SDK accepts any value. The other official SDKs already enforce this (TypeScript, Python, and PHP). This follows the existing pattern in `MCP::Icon`, which validates its constrained `theme` value in the constructor and raises `ArgumentError`. Added tests in `test/mcp/annotations_test.rb`: - valid `audience` (`["user"]`, `["assistant"]`, `["user", "assistant"]`, and an empty array) is accepted - an unknown role, a mixed valid/invalid array, or a non-array `audience` raises `ArgumentError` Existing tests used `["developers"]`, which is not a valid `Role`, and are updated to `["user"]`. The full unit suite and RuboCop pass locally. Strictly speaking this is a breaking change: a caller that currently passes an invalid `audience` (e.g. `["developers"]`) will now get an `ArgumentError`. Such values already violate the MCP specification, and `nil` / valid-role values are unaffected. --- lib/mcp/annotations.rb | 5 +++ test/mcp/annotations_test.rb | 63 ++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/mcp/annotations.rb b/lib/mcp/annotations.rb index 540588a..f8562f2 100644 --- a/lib/mcp/annotations.rb +++ b/lib/mcp/annotations.rb @@ -2,9 +2,14 @@ module MCP class Annotations + SUPPORTED_AUDIENCES = ["user", "assistant"].freeze + attr_reader :audience, :priority, :last_modified def initialize(audience: nil, priority: nil, last_modified: nil) + if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) }) + raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".' + end raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1) @audience = audience diff --git a/test/mcp/annotations_test.rb b/test/mcp/annotations_test.rb index a82b955..1e5d3df 100644 --- a/test/mcp/annotations_test.rb +++ b/test/mcp/annotations_test.rb @@ -5,24 +5,24 @@ require "test_helper" module MCP class AnnotationsTest < ActiveSupport::TestCase def test_initialization - annotations = Annotations.new(audience: ["developers"], priority: 0.8) + annotations = Annotations.new(audience: ["user"], priority: 0.8) - assert_equal(["developers"], annotations.audience) + assert_equal(["user"], annotations.audience) assert_equal(0.8, annotations.priority) assert_nil(annotations.last_modified) - assert_equal({ audience: ["developers"], priority: 0.8 }, annotations.to_h) + assert_equal({ audience: ["user"], priority: 0.8 }, annotations.to_h) end def test_initialization_with_all_attributes timestamp = Time.utc(2025, 1, 12, 15, 0, 58).iso8601 - annotations = Annotations.new(audience: ["developers"], priority: 0.8, last_modified: timestamp) + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: timestamp) - assert_equal(["developers"], annotations.audience) + assert_equal(["user"], annotations.audience) assert_equal(0.8, annotations.priority) assert_equal(timestamp, annotations.last_modified) - assert_equal({ audience: ["developers"], priority: 0.8, lastModified: timestamp }, annotations.to_h) + assert_equal({ audience: ["user"], priority: 0.8, lastModified: timestamp }, annotations.to_h) end def test_initialization_by_default @@ -36,13 +36,13 @@ module MCP end def test_initialization_with_partial_attributes - annotations = Annotations.new(audience: ["developers"]) + annotations = Annotations.new(audience: ["user"]) - assert_equal(["developers"], annotations.audience) + assert_equal(["user"], annotations.audience) assert_nil(annotations.priority) assert_nil(annotations.last_modified) - assert_equal({ audience: ["developers"] }, annotations.to_h) + assert_equal({ audience: ["user"] }, annotations.to_h) end def test_initialization_with_last_modified_only @@ -81,5 +81,50 @@ module MCP end assert_equal("The value of priority must be between 0 and 1.", exception.message) end + + def test_valid_audience_with_user + assert_nothing_raised do + Annotations.new(audience: ["user"]) + end + end + + def test_valid_audience_with_assistant + assert_nothing_raised do + Annotations.new(audience: ["assistant"]) + end + end + + def test_valid_audience_with_multiple_roles + assert_nothing_raised do + Annotations.new(audience: ["user", "assistant"]) + end + end + + def test_valid_audience_with_empty_array + assert_nothing_raised do + Annotations.new(audience: []) + end + end + + def test_invalid_audience_with_unknown_role + exception = assert_raises(ArgumentError) do + Annotations.new(audience: ["developers"]) + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end + + def test_invalid_audience_with_mixed_roles + exception = assert_raises(ArgumentError) do + Annotations.new(audience: ["user", "developers"]) + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end + + def test_invalid_audience_when_not_array + exception = assert_raises(ArgumentError) do + Annotations.new(audience: "user") + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end end end From da8e7e5363f307f0329f69c3a15e3797d707d357 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:13:56 +0000 Subject: [PATCH 118/247] Bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 6 +++--- .github/workflows/conformance.yml | 2 +- .github/workflows/release.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6610745..76578ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - { ruby: 'head', allowed-failure: true } name: Test Ruby ${{ matrix.entry.ruby }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.entry.ruby }} @@ -33,7 +33,7 @@ jobs: contents: read name: RuboCop steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: 4.0 # Specify the latest supported Ruby version. @@ -46,7 +46,7 @@ jobs: contents: read name: YARD Documentation steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: 4.0 # Specify the latest supported Ruby version. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4ab3297..9e708ed 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: '4.0' # Specify the latest supported Ruby version. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32e7957..e343d22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -34,7 +34,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Fetch all history for all branches and tags From a4cd7c8cde772d60861c6a8255c92909ad210f3d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 30 Apr 2026 02:41:16 +0900 Subject: [PATCH 119/247] Support client-side `notifications/cancelled` per MCP specification ## Motivation and Context The server-side half of MCP `notifications/cancelled` shipped earlier and lets handlers observe cancellation via `server_context.cancelled?` / `raise_if_cancelled!`. This PR delivers the client-side equivalent that was deferred at the time: an `MCP::Client` user can now cancel a request they have already issued and have the calling thread wake up, matching the Python SDK (`anyio.CancelScope`) and TypeScript SDK (`AbortSignal`) ergonomics. The recommended pattern is to pass an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call `cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to the server, and the calling thread is woken up with `MCP::CancelledError`: ```ruby cancellation = MCP::Cancellation.new Thread.new do client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) rescue MCP::CancelledError # cleanup end cancellation.cancel(reason: "user pressed cancel") ``` For low-level use, `Client#cancel(request_id:, reason:)` sends the notification without managing the calling thread, and `Client#generate_request_id` lets callers pre-allocate an id before kicking off a request on another thread. The `request_id:` and `cancellation:` keywords are accepted by every request method (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`). When `cancellation:` is supplied, the actual blocking `transport.send_request` runs on a worker thread; the calling thread waits on a `Queue` woken either by the response or by the cancel signal (whichever arrives first - the same race contract as the server-side `StreamableHTTPTransport#cancel_pending_request`). On a normal completion the `on_cancel` hook is deregistered so a late cancel does not emit a stray `notifications/cancelled`. The worker thread is *not* force-killed when a cancel wins the race; it stays blocked on the underlying I/O until the transport actually returns (or the user closes it). For `Client::HTTP` the leak resolves as soon as the server sends any response; for `Client::Stdio` the user may need to call `client.transport.close` if the server stops responding entirely. `Client::Stdio` and `Client::HTTP` gain `send_notification(notification:)` so `Client#cancel` can deliver the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implement `send_notification` cause `Client#cancel` to raise `NoMethodError`. Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation ## How Has This Been Tested? `test/mcp/client_test.rb` covers `Client#cancel` payload (with and without `reason`), `Client#generate_request_id` returning distinct UUIDs, every request method threading `request_id:` through, and the full `cancellation:` flow: - Returns the response when no cancel happens (no behaviour regression for callers that do not pass a token). - Pre-cancelled token raises `MCP::CancelledError` immediately and never reaches the transport (no stray request, no stray notification). - Mid-flight cancel raises `MCP::CancelledError` carrying the supplied reason and sends `notifications/cancelled` through the transport. - Late cancel after a normal completion does not emit a stray notification (the `on_cancel` hook is deregistered in the `ensure` block). `test/mcp/client/stdio_test.rb` covers `send_notification` writing the JSON line through the spawned process and returning `nil` without waiting for a response. `test/mcp/client/http_test.rb` covers `send_notification` POSTing the body, tolerating HTTP 202 Accepted, and surfacing Faraday errors as `RequestHandlerError`. ## Breaking Change None. All new keywords (`request_id:`, `cancellation:`) on request methods are optional and default to `nil`. Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already implement `send_request(request:)` continue to work; only callers of `Client#cancel` need a transport that responds to `send_notification`, and the failure mode is a clear `NoMethodError` rather than silent breakage. --- README.md | 63 ++++- lib/mcp/client.rb | 224 +++++++++++++++--- lib/mcp/client/http.rb | 33 ++- lib/mcp/client/stdio.rb | 22 +- test/mcp/client/http_test.rb | 31 +++ test/mcp/client/stdio_test.rb | 115 ++++++++++ test/mcp/client_test.rb | 331 +++++++++++++++++++++++++++ test/mcp/server_cancellation_test.rb | 2 +- 8 files changed, 779 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index d316da5..2b08cfe 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports roots (server-to-client filesystem boundary queries) - Supports sampling (server-to-client LLM completion requests) - Supports cursor-based pagination for list operations -- Supports server-side cancellation of in-flight requests (notifications/cancelled) +- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) ### Supported Methods @@ -1205,12 +1205,7 @@ poll it to exit early. When a tool returns after cancellation has been observed, the server suppresses the JSON-RPC response, matching the spec. The `initialize` request is never cancellable per the spec. -> [!NOTE] -> Client-initiated cancellation (`Client#cancel` equivalent that would also abort -> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled` -> from the client side can be done by constructing the notification payload and writing it -> directly through the transport, but the calling thread does not yet unwind automatically. -> This is tracked as a follow-up. +Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below. #### Server-Side: Handlers that Check for Cancellation @@ -1319,6 +1314,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only. the parent `tools/call` is cancelled. The parent tool itself still observes cancellation via `server_context.cancelled?` between nested calls. +#### Client-Side: Cancelling an In-Flight Request + +`MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass +an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call +`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to +the server, and the calling thread is woken up with `MCP::CancelledError`: + +```ruby +client = MCP::Client.new(transport: transport) +cancellation = MCP::Cancellation.new + +Thread.new do + client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) +rescue MCP::CancelledError + # cleanup +end + +# Later, from another thread: +cancellation.cancel(reason: "user pressed cancel") +``` + +All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, +`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword. +Request ids are managed internally, so the token is the only thing a caller needs to cancel a request. + +> [!NOTE] +> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed; +> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side +> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` +> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` +> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal +> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at +> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. + +##### Wire-order guarantees + +`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, +so the server is guaranteed to read the request line before the cancel line. + +`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, +so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST +on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and +still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)), +and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST +happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. + +##### Custom transports + +Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. +They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire +(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). +The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for +the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. + ### Ping The MCP Ruby SDK supports the diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 68c2ee4..fbf04f3 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -107,6 +107,8 @@ module MCP # @param cursor [String, nil] Cursor from a previous page response. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional token; cancelling it sends + # `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call. # @return [MCP::Client::ListToolsResult] Result with `tools` (Array) # and `next_cursor` (String or nil). # @@ -118,9 +120,9 @@ module MCP # cursor = page.next_cursor # break unless cursor # end - def list_tools(cursor: nil, meta: nil) + def list_tools(cursor: nil, meta: nil, cancellation: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "tools/list", params: params, meta: meta) + response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} tools = (result["tools"] || []).map do |tool| @@ -141,6 +143,9 @@ module MCP # # Each call will make a new request - the result is not cached. # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # Cancelling it aborts whichever page is currently in flight; pages already returned are kept, + # but the call raises `MCP::CancelledError` instead of returning the partial set. # @return [Array] An array of available tools. # # @example @@ -148,9 +153,9 @@ module MCP # tools.each do |tool| # puts tool.name # end - def tools + def tools(cancellation: nil) # TODO: consider renaming to `list_all_tools`. - fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools) + fetch_all_pages { |cursor| list_tools(cursor: cursor, cancellation: cancellation) }.flat_map(&:tools) end # Returns a single page of resources from the server. @@ -158,11 +163,12 @@ module MCP # @param cursor [String, nil] Cursor from a previous page response. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array) # and `next_cursor` (String or nil). - def list_resources(cursor: nil, meta: nil) + def list_resources(cursor: nil, meta: nil, cancellation: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "resources/list", params: params, meta: meta) + response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} ListResourcesResult.new( @@ -178,10 +184,11 @@ module MCP # # Each call will make a new request - the result is not cached. # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resources. - def resources + def resources(cancellation: nil) # TODO: consider renaming to `list_all_resources`. - fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources) + fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources) end # Returns a single page of resource templates from the server. @@ -189,11 +196,12 @@ module MCP # @param cursor [String, nil] Cursor from a previous page response. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates` # (Array) and `next_cursor` (String or nil). - def list_resource_templates(cursor: nil, meta: nil) + def list_resource_templates(cursor: nil, meta: nil, cancellation: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "resources/templates/list", params: params, meta: meta) + response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} ListResourceTemplatesResult.new( @@ -209,10 +217,11 @@ module MCP # # Each call will make a new request - the result is not cached. # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resource templates. - def resource_templates + def resource_templates(cancellation: nil) # TODO: consider renaming to `list_all_resource_templates`. - fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates) + fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates) end # Returns a single page of prompts from the server. @@ -220,11 +229,12 @@ module MCP # @param cursor [String, nil] Cursor from a previous page response. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array) # and `next_cursor` (String or nil). - def list_prompts(cursor: nil, meta: nil) + def list_prompts(cursor: nil, meta: nil, cancellation: nil) params = cursor ? { cursor: cursor } : nil - response = request(method: "prompts/list", params: params, meta: meta) + response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} ListPromptsResult.new( @@ -240,10 +250,11 @@ module MCP # # Each call will make a new request - the result is not cached. # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available prompts. - def prompts + def prompts(cancellation: nil) # TODO: consider renaming to `list_all_prompts`. - fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts) + fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts) end # Calls a tool via the transport layer and returns the full response from the server. @@ -256,6 +267,8 @@ module MCP # e.g. the W3C Trace Context keys reserved by SEP-414 # (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`). # `progress_token` takes precedence over a `progressToken` entry in `meta`. + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. Cancelling it from another thread + # sends `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call. # @return [Hash] The full JSON-RPC response from the transport. # # @example Call by name @@ -267,10 +280,19 @@ module MCP # response = client.call_tool(tool: tool, arguments: { foo: "bar" }) # structured_content = response.dig("result", "structuredContent") # + # @example Cancellable call + # cancellation = MCP::Cancellation.new + # Thread.new do + # client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) + # rescue MCP::CancelledError + # # cleanup + # end + # cancellation.cancel(reason: "user pressed cancel") + # # @note # The exact requirements for `arguments` are determined by the transport layer in use. # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil) + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name @@ -282,7 +304,7 @@ module MCP end params[:_meta] = meta_entries unless meta_entries.empty? - request(method: "tools/call", params: params) + request(method: "tools/call", params: params, cancellation: cancellation) end # Reads a resource from the server by URI and returns the contents. @@ -290,9 +312,10 @@ module MCP # @param uri [String] The URI of the resource to read. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:, meta: nil) - response = request(method: "resources/read", params: { uri: uri }, meta: meta) + def read_resource(uri:, meta: nil, cancellation: nil) + response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation) response.dig("result", "contents") || [] end @@ -302,9 +325,10 @@ module MCP # @param name [String] The name of the prompt to get. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] A hash containing the prompt details. - def get_prompt(name:, meta: nil) - response = request(method: "prompts/get", params: { name: name }, meta: meta) + def get_prompt(name:, meta: nil, cancellation: nil) + response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation) response.fetch("result", {}) end @@ -317,12 +341,13 @@ module MCP # @param context [Hash, nil] Optional context with previously resolved arguments. # @param meta [Hash, nil] Additional `_meta` entries to send with the request, # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`. - def complete(ref:, argument:, context: nil, meta: nil) + def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil) params = { ref: ref, argument: argument } params[:context] = context if context - response = request(method: "completion/complete", params: params, meta: meta) + response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation) response.dig("result", "completion") || { "values" => [], "hasMore" => false } end @@ -330,6 +355,9 @@ module MCP # Sends a `ping` request to the server to verify the connection is alive. # Per the MCP spec, the server responds with an empty result. # + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] An empty hash on success. # @raise [ServerError] If the server returns a JSON-RPC error. # @raise [ValidationError] If the response `result` is missing or not a Hash. @@ -338,8 +366,8 @@ module MCP # client.ping # => {} # # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping - def ping(meta: nil) - result = request(method: Methods::PING, meta: meta)["result"] + def ping(meta: nil, cancellation: nil) + result = request(method: Methods::PING, meta: meta, cancellation: cancellation)["result"] raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) result @@ -372,17 +400,21 @@ module MCP # without mutating the caller's hashes. Per SEP-414, `_meta` carries # request-specific metadata such as W3C trace context (`traceparent`, # `tracestate`, `baggage`); see {MCP::TraceContext}. - def request(method:, params: nil, meta: nil) + def request(method:, params: nil, meta: nil, cancellation: nil) params = (params || {}).merge(_meta: meta) if meta && !meta.empty? request_body = { jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, + id: generate_request_id, method: method, } request_body[:params] = params if params - response = transport.send_request(request: request_body) + response = if cancellation + dispatch_with_cancellation(request_body, cancellation) + else + transport.send_request(request: request_body) + end # Guard with `is_a?(Hash)` because custom transports may return non-Hash values. if response.is_a?(Hash) && response.key?("error") @@ -393,8 +425,140 @@ module MCP response end - def request_id + # Generates a fresh JSON-RPC request id for an outgoing request. + # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through + # an `MCP::Cancellation` token instead. + def generate_request_id SecureRandom.uuid end + + # Sends `request_body` while watching `cancellation`. The actual blocking `transport.send_request` runs on + # a worker thread; the calling thread waits on a Queue that is woken either by the response or by a cancel signal + # (whichever arrives first - matching the server-side `StreamableHTTPTransport#cancel_pending_request` race contract). + # + # When a cancel wins the race, the calling thread raises `MCP::CancelledError` immediately and the `notifications/cancelled` + # dispatch runs fire-and-forget on its own thread. We deliberately do not wait for that dispatch here: the calling thread + # must not be blocked by a slow or stalled transport write on the cancel path. + # The worker thread is also not force-killed; it stays blocked on the underlying I/O until the server actually responds + # (or the transport closes). This is the same trade-off the server-side `StreamableHTTPTransport#send_request` accepts and + # is noted in the README's Cancellation section. + def dispatch_with_cancellation(request_body, cancellation) + unless transport.respond_to?(:send_notification) + raise NoMethodError, "Cancellation support requires a transport that responds to `send_notification(notification:)` " \ + "so `notifications/cancelled` can be delivered to the peer. The bundled `MCP::Client::Stdio` and `MCP::Client::HTTP` transports " \ + "implement this interface; custom transports must add it before passing `cancellation:` to a request method." + end + + cancellation.raise_if_cancelled! + + request_id = request_body[:id] + queue = Queue.new + + # First-writer-wins gate. Whichever side (worker or on_cancel) flips `completed` first owns the queue's single slot; the loser bails. + # This closes the late-cancel window between the worker pushing `:response` and the main thread completing `dispatch_with_cancellation`, + # where a callback firing in that gap would otherwise emit a stray `notifications/cancelled` for a request that already succeeded. + completion_mutex = Mutex.new + completed = false + sent_mutex = Mutex.new + sent_cond = ConditionVariable.new + request_sent = false + signal_sent = lambda do + sent_mutex.synchronize do + unless request_sent + request_sent = true + sent_cond.broadcast + end + end + end + + Thread.new do + Thread.current.report_on_exception = false + begin + result = transport.send_request(request: request_body, &signal_sent) + completion_mutex.synchronize do + next if completed + + completed = true + queue.push([:response, result]) + end + rescue StandardError => e + completion_mutex.synchronize do + next if completed + + completed = true + queue.push([:error, e]) + end + ensure + # Unblock any waiting cancel-dispatch thread on completion (or error) + # so it does not stall when the transport ignored the block. + signal_sent.call + end + end + + cancel_hook = cancellation.on_cancel do |reason| + should_dispatch = completion_mutex.synchronize do + next false if completed + + completed = true + + # Wake the waiting thread first, then dispatch the `notifications/cancelled` send on a separate thread. + # The wake-first ordering matters because the cancellation callback can run on the worker thread itself + # (e.g. a tool that triggers cancel from within `transport.send_request`), and a synchronous `send_notification` + # here would deadlock when the worker holds a transport-level mutex. + queue.push([:cancelled, reason]) + true + end + + next unless should_dispatch + + Thread.new do + Thread.current.report_on_exception = false + # Wait for the worker's send-boundary signal before issuing `notifications/cancelled`. Bundled transports raise + # the signal via `&on_sent` from inside `send_request`; custom transports that ignore the block still raise it + # via the worker's `ensure -> signal_sent.call`, so the loop is bounded by worker termination rather than by wall-clock time. + # The previous fixed-duration fallback could release this thread before the worker reached its send-boundary at all, + # allowing the cancel to be issued without any prior request commitment - which the spec only covers under + # the receiver's MAY-ignore-unknown-id clause and is therefore avoided here. + sent_mutex.synchronize do + sent_cond.wait(sent_mutex) until request_sent + end + cancel(request_id: request_id, reason: reason) + rescue StandardError + # Swallow notification-send failures: the calling thread has already been woken with `:cancelled` above and + # is on its way to raising `MCP::CancelledError`. + end + end + + tag, payload = queue.pop + + case tag + when :response + payload + when :error + raise payload + when :cancelled + raise MCP::CancelledError.new(request_id: request_id, reason: payload) + end + ensure + cancellation&.off_cancel(cancel_hook) if cancel_hook + end + + # Sends `notifications/cancelled` to the server for an in-flight request. + # Per spec, this is fire-and-forget: the server is expected to stop processing and suppress its response, + # with no acknowledgement returned. Driven internally by {#dispatch_with_cancellation} when a request's + # `cancellation` token fires. + def cancel(request_id:, reason: nil) + params = { requestId: request_id } + params[:reason] = reason if reason + + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: Methods::NOTIFICATIONS_CANCELLED, + params: params, + } + + transport.send_notification(notification: notification) + nil + end end end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 273a756..e23c154 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -178,9 +178,18 @@ module MCP end # Sends a JSON-RPC request and returns the parsed response body. - # After a successful `initialize` handshake, the session ID and protocol - # version returned by the server are captured and automatically included - # on subsequent requests. + # After a successful `initialize` handshake, the session ID and protocol version returned by + # the server are captured and automatically included on subsequent requests. + # + # If a block is given, it is invoked just before Faraday's `post` is called. + # Faraday's synchronous `post` does not expose a post-write / pre-response hook, + # so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens + # inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release + # the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST + # that may overlap with the original request on the network. The spec covers this: + # the sender has issued the request and still believes it in-progress, and receivers MAY ignore + # a cancellation referring to an unknown request id when the cancel POST happens to arrive first. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] @@ -188,6 +197,7 @@ module MCP step_up_retried = false begin + yield if block_given? response = client.post("", request, session_headers) body = parse_response_body(response, method, params) @@ -274,6 +284,23 @@ module MCP end end + # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver + # `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted + # per the Streamable HTTP spec. + def send_notification(notification:) + method = notification[:method] || notification["method"] + + client.post("", notification, session_headers) + nil + rescue Faraday::Error => e + raise RequestHandlerError.new( + "Failed to send #{method} notification", + { method: method }, + error_type: :internal_error, + original_error: e, + ) + end + # Terminates the session by sending an HTTP DELETE to the MCP endpoint # with the current `Mcp-Session-Id` header, and clears locally tracked # session state afterward. No-op when no session has been established. diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index a273c57..222add2 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -34,6 +34,9 @@ module MCP @started = false @initialized = false @server_info = nil + # Serializes writes to `@stdin` so a request line and a notification line emitted from + # different threads (e.g. cancellation) cannot interleave on the wire. + @write_mutex = Mutex.new end # Performs the MCP `initialize` handshake: sends an `initialize` request @@ -132,6 +135,10 @@ module MCP @initialized end + # Transports may yield once the request line has been written to `@stdin`. + # `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled` + # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`, + # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request. def send_request(request:) start unless @started unless @initialized @@ -139,10 +146,23 @@ module MCP connect end - write_message(request) + @write_mutex.synchronize do + write_message(request) + yield if block_given? + end read_response(request) end + # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver + # `notifications/cancelled` for an in-flight request. + def send_notification(notification:) + start unless @started + connect unless @initialized + + @write_mutex.synchronize { write_message(notification) } + nil + end + def start raise "MCP::Client::Stdio already started" if @started diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 5a90fde..c1d6c4a 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -942,6 +942,37 @@ module MCP assert_equal("2025-11-25", client.protocol_version) end + def test_send_notification_posts_body_and_accepts_202 + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "req-1", reason: "user cancel" }, + } + + stub_request(:post, url).with(body: notification.to_json).to_return(status: 202, body: "") + + result = client.send_notification(notification: notification) + + assert_nil(result, "send_notification must return nil") + end + + def test_send_notification_surfaces_faraday_errors + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "req-1" }, + } + + stub_request(:post, url).to_return(status: 500) + + error = assert_raises(RequestHandlerError) do + client.send_notification(notification: notification) + end + + assert_equal(:internal_error, error.error_type) + assert_match(%r{notifications/cancelled}, error.message) + end + private def initialize_session diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index ce01fb9..3516f4e 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1226,6 +1226,121 @@ module MCP end end + def test_send_notification_writes_json_line_without_waiting_for_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "abc-123", reason: "user cancel" }, + } + + # Server: respond to initialize, then read the notification line. + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test", version: "1.0.0" } }, + })) + stdout_write.flush + + stdin_read.gets # skip initialized notification + + stdin_read.gets # read the cancellation notification line we are testing + end + + result = transport.send_notification(notification: notification) + + assert_nil(result, "send_notification must return nil (no response expected)") + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_concurrent_write_message_does_not_interleave_lines + # Regression: cancellation can call `send_notification` from a thread while `send_request` is mid-flight + # on another thread. The two writes must serialize so JSON-RPC lines do not interleave on stdin. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + # Server: respond to initialize, then accept multiple lines. + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "t", version: "1.0.0" } }, + })) + stdout_write.flush + stdin_read.gets # initialized + end + + # Force initialization synchronously. + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "warmup" }, + } + transport.send_notification(notification: notification) + server_thread.join + + # Now hammer send_notification from many threads concurrently. Each line is one JSON object terminated by `\n`; + # if writes interleave, the received text would not be one valid JSON object per line. + threads = 8.times.map do |i| + Thread.new do + 10.times do |j| + transport.send_notification( + notification: { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "t#{i}-#{j}" }, + }, + ) + end + end + end + threads.each(&:join) + + # Drain everything written to stdin and verify each line parses as JSON. + stdin_write.close + lines = [] + until stdin_read.eof? + line = stdin_read.gets + break if line.nil? + + lines << line + end + lines.each do |line| + assert(JSON.parse(line.strip), "interleaved write produced unparseable line: #{line.inspect}") + end + assert_operator(lines.size, :>=, 80, "expected at least 80 notification lines") + ensure + stdout_read.close + stdout_write.close + begin + stderr_read.close + rescue + nil + end + end + private def assert_implicit_connect_deprecation_warning(&block) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index cdf9d87..39fc47c 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -1142,5 +1142,336 @@ module MCP assert_equal(1, result.prompts.size) assert_equal("cursor1", result.next_cursor) end + + def test_cancellation_without_reason_omits_reason_in_notification + # The token-driven cancel path must omit `reason` from the `notifications/cancelled` params + # when the token is cancelled without one. + sent = Queue.new + cancellation = MCP::Cancellation.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + cancellation.cancel # no reason + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + sent.push(notification) + nil + end + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + notification = sent.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + refute(notification[:params].key?(:reason)) + end + + def test_call_tool_with_cancellation_returns_response_when_no_cancel + transport = mock + transport.expects(:send_request).returns({ "result" => { "content" => [{ "type" => "text", "text" => "ok" }] } }).once + transport.stubs(:send_notification) # required for `cancellation:` capability check + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + assert_equal("ok", response.dig("result", "content", 0, "text")) + end + + def test_call_tool_with_cancellation_already_cancelled_raises_without_sending + transport = mock + transport.expects(:send_request).never + transport.expects(:send_notification).never + + cancellation = MCP::Cancellation.new + cancellation.cancel(reason: "pre-cancelled") + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + end + + def test_call_tool_with_cancellation_mid_flight_raises_and_sends_notification + cancellation = MCP::Cancellation.new + notification_queue = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + # Cancel while the request is "in flight". + cancellation.cancel(reason: "user abort") + sleep(0.05) + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + notification_queue.push(notification) + nil + end + + client = Client.new(transport: transport) + + error = assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + assert_equal("user abort", error.reason) + + # Cancel notification is dispatched fire-and-forget on a separate thread; + # block here until it reaches the transport. + notification = notification_queue.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + assert_equal("user abort", notification[:params][:reason]) + end + + def test_loop_methods_accept_cancellation_keyword + # Regression: `tools` / `resources` / `resource_templates` / `prompts` are the high-level pagination loops. + # They must accept `cancellation:` so the README's "all request methods accept the cancellation: keyword" + # claim holds. The token is propagated to each underlying `list_*` page. + cancellation = MCP::Cancellation.new + + transport = mock + transport.stubs(:send_request).returns({ "result" => { "tools" => [] } }) + transport.stubs(:send_notification) # required for `cancellation:` capability check + + client = Client.new(transport: transport) + + [ + -> { client.tools(cancellation: cancellation) }, + -> { client.resources(cancellation: cancellation) }, + -> { client.resource_templates(cancellation: cancellation) }, + -> { client.prompts(cancellation: cancellation) }, + ].each do |loop_call| + transport.stubs(:send_request).returns({ "result" => {} }) + loop_call.call + end + end + + def test_cancel_callback_does_not_deadlock_when_transport_holds_a_mutex + # Regression: when the cancellation callback fires on the same thread that is currently inside + # `transport.send_request` (and therefore holds a transport-level mutex), a synchronous `send_notification` + # from the callback would deadlock by re-entering the same mutex. The fix wakes the calling thread first + # and dispatches the notification on a separate thread. + transport_mutex = Mutex.new + cancellation = MCP::Cancellation.new + + # Custom transport class so we can model a mutex held during send_request. + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + transport_mutex.synchronize do + # Trigger cancel while we hold the mutex. The callback must NOT try to synchronously acquire + # the same mutex via send_notification. + cancellation.cancel(reason: "mid-flight") + # Hold the mutex briefly to give the callback time to (incorrectly) try to re-enter. + sleep(0.05) + { "result" => {} } + end + end + + # send_notification also needs the mutex - this is the path that would deadlock under synchronous dispatch. + send_notification_called = Queue.new + transport.define_singleton_method(:send_notification) do |notification:| + transport_mutex.synchronize do + send_notification_called.push(notification) + end + end + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + + # The notification must reach the transport eventually (after the mutex is released by the now-finished send_request). + notification = send_notification_called.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + assert_equal("mid-flight", notification[:params][:reason]) + end + + def test_late_cancel_after_response_does_not_send_stray_notification + # Regression: between the worker thread pushing `:response` and the main thread leaving `dispatch_with_cancellation`, + # a cancellation firing in that gap must not emit a stray `notifications/cancelled` for the already-completed request. + # The completion-mutex gate makes the worker and the on_cancel callback fight for a single slot; whichever sets + # `completed` first wins, the loser bails. + cancellation = MCP::Cancellation.new + notification_count = 0 + notification_count_mutex = Mutex.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**, &on_sent| + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |**| + notification_count_mutex.synchronize { notification_count += 1 } + nil + end + + client = Client.new(transport: transport) + response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + assert_equal({}, response["result"]) + + # Cancel after the call returned. The on_cancel hook was deregistered in the `ensure` block; + # even if a callback raced with the response handoff, `completed` would already be true and + # `queue.push :cancelled` plus the cancel-dispatch thread would not run. + cancellation.cancel(reason: "late") + + sleep(0.05) # let any erroneous cancel-dispatch thread complete. + + count = notification_count_mutex.synchronize { notification_count } + assert_equal(0, count, "no stray notifications/cancelled for an already-completed request") + end + + def test_call_tool_with_cancellation_raises_NoMethodError_when_transport_lacks_send_notification + # Regression: a transport that only implements `send_request` cannot deliver `notifications/cancelled`. + # We must surface this upfront with a clear `NoMethodError` rather than silently swallow + # the failure inside the cancel-dispatch thread - otherwise the user sees `CancelledError` but + # the server never receives the notification. + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + { "result" => {} } + end + # NOTE: no send_notification. + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + error = assert_raises(NoMethodError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + + assert_match(/send_notification/, error.message) + end + + def test_cancel_notification_is_dispatched_after_request_write_via_on_sent_block + # Regression for the wire-order race: a transport that yields from `send_request` after writing + # the request must see `:request` recorded before `:cancel` even when the cancel signal arrives concurrently. + recorded = [] + recorded_mutex = Mutex.new + cancellation = MCP::Cancellation.new + cancel_complete = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |request:, &on_sent| + # Trigger cancel BEFORE the simulated wire-write so the cancel-dispatch thread starts racing immediately. + # The `on_sent` block is yielded only after we have recorded the request - so any cancel-write that obeys + # the block must land afterwards. + cancellation.cancel(reason: "race") + sleep(0.02) # give cancel-dispatch thread time to spawn and start waiting + recorded_mutex.synchronize { recorded << [:request, request[:id]] } + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] } + cancel_complete.push(:done) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-A") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + cancel_complete.pop # wait for the fire-and-forget cancel dispatch + + # Wire-order must be: request first, then cancel. + assert_equal([[:request, "req-A"], [:cancel, "req-A"]], recorded) + end + + def test_cancel_notification_waits_for_delayed_on_sent_signal + # Regression: the cancel-dispatch thread must wait for `&on_sent` no matter how long it takes - + # it cannot fall back to wall-clock-based timeout. An earlier 100 ms fixed-duration fallback + # could release the cancel thread before the worker reached its send-boundary at all, allowing + # the cancel to be issued without any prior request commitment - which the spec only covers under + # the receiver's MAY-ignore-unknown-id clause. The wait is now bounded by worker termination + # (via `ensure -> signal_sent.call`), not by elapsed time. + recorded = [] + recorded_mutex = Mutex.new + cancellation = MCP::Cancellation.new + cancel_complete = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |request:, &on_sent| + cancellation.cancel(reason: "race") + # Delay well beyond any plausible fixed-duration fallback (200 ms). + # If cancel-dispatch fires on a timer rather than waiting for on_sent, + # the recorded order will be [:cancel, :request] and the assertion below will fail. + sleep(0.2) + recorded_mutex.synchronize { recorded << [:request, request[:id]] } + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] } + cancel_complete.push(:done) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-D") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + cancel_complete.pop + + assert_equal([[:request, "req-D"], [:cancel, "req-D"]], recorded) + end + + def test_cancel_notification_dispatches_when_worker_raises_before_on_sent + # Regression: if the worker raises before `&on_sent` is called - which can happen if a transport rejects + # a malformed request synchronously - the cancel-dispatch thread must still terminate AND deliver the notification. + # The worker's `ensure` block invokes `signal_sent.call`, which broadcasts to the condition variable and unblocks + # the `until request_sent` wait. Without the ensure-driven unblock, the dispatch thread would leak. + cancellation = MCP::Cancellation.new + send_notification_called = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + # Trigger cancel before raising so the cancel hook fires while the worker is mid-flight, + # then bail out without ever calling on_sent. + cancellation.cancel(reason: "race") + raise StandardError, "transport boom" + end + transport.define_singleton_method(:send_notification) do |notification:| + send_notification_called.push(notification) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-E") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + # The cancel-dispatch thread should still send the notification because the worker's `ensure` triggers `signal_sent.call`. + notification = send_notification_called.pop + assert_equal("req-E", notification[:params][:requestId]) + end + + def test_call_tool_with_cancellation_normal_completion_does_not_send_notification + transport = mock + transport.expects(:send_request).returns({ "result" => {} }).once + transport.expects(:send_notification).never + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + # Late cancel after normal completion: hook should already be deregistered. + cancellation.cancel(reason: "late") + end end end diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index b061508..52679c7 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -118,7 +118,7 @@ module MCP end test "CancelledError raised from nested server-to-client call suppresses response" do - @server.define_tool(name: "nesting") do |server_context:| # rubocop:disable Lint/UnusedBlockArgument + @server.define_tool(name: "nesting") do |**| # Simulate a transport-level CancelledError coming back from a nested # send_request (e.g. sampling/createMessage after parent was cancelled). raise MCP::CancelledError.new(request_id: "nested-xyz", reason: "parent aborted") From a284bf7314c336e40eee00e141d50748d6bfbad8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 7 Jun 2026 03:06:47 +0900 Subject: [PATCH 120/247] Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification ## Motivation and Context The MCP specification (SEP-2243) requires the Streamable HTTP transport to mirror selected JSON-RPC body fields into HTTP headers so that intermediaries such as load balancers, gateways, and observability tooling can route and inspect requests without parsing the body. `Mcp-Method` carries the JSON-RPC `method` and is required on all requests and notifications, while `Mcp-Name` carries `params.name` (`tools/call`, `prompts/get`) or `params.uri` (`resources/read`). The Ruby SDK client did not send these headers, so servers enforcing SEP-2243 rejected its requests with `400 Bad Request`, breaking interoperability. This aligns the Ruby SDK with the TypeScript and Python SDKs, both of which send these headers from their Streamable HTTP clients. Header values that cannot be transmitted safely as-is, such as non-ASCII text, control characters (including CR/LF, which would otherwise allow header injection), or significant leading or trailing whitespace, are wrapped per SEP-2243 as `=?base64??=`. Safe ASCII values are sent unchanged. The encoding uses `Array#pack("m0")` to avoid depending on the `base64` gem. Resolves #366 ## How Has This Been Tested? Client tests cover deriving `Mcp-Name` from `params.name` (`tools/call`, `prompts/get`) and falling back to `params.uri` (`resources/read`), both symbol- and string-keyed params, requests and notifications without a name or uri sending only `Mcp-Method`, and non-Hash params sending only `Mcp-Method`. Value encoding is covered for non-ASCII names, surrounding whitespace, CR/LF (verifying no header injection), and values that already match the `=?base64?...?=` sentinel. ## Breaking Changes None. The change is purely additive: existing requests now carry the required `Mcp-Method` and `Mcp-Name` headers alongside the headers already sent. The `x-mcp-header` / `Mcp-Param-*` custom headers remain out of scope, matching the current state of the TypeScript and Python SDKs. --- lib/mcp/client/http.rb | 37 +++++- test/mcp/client/http_test.rb | 232 +++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index e23c154..3a2a2ac 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -16,6 +16,8 @@ module MCP ACCEPT_HEADER = "application/json, text/event-stream" SESSION_ID_HEADER = "Mcp-Session-Id" PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" + METHOD_HEADER = "Mcp-Method" + NAME_HEADER = "Mcp-Name" # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host @@ -198,7 +200,7 @@ module MCP begin yield if block_given? - response = client.post("", request, session_headers) + response = client.post("", request, session_headers.merge(request_metadata_headers(method, params))) body = parse_response_body(response, method, params) capture_session_info(method, response, body) @@ -368,6 +370,39 @@ module MCP request_headers end + # Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries + # can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name` + # (tools, prompts) or, when absent, `params.uri` (resources). + # + # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata + def request_metadata_headers(method, params) + return {} unless method + + metadata_headers = { METHOD_HEADER => method } + if params.is_a?(Hash) + name = params[:name] || params["name"] + name = params[:uri] || params["uri"] unless name.is_a?(String) + metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String) + end + + metadata_headers + end + + # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF, + # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as + # `=?base64??=`. Safe ASCII values are sent unchanged. + def encode_header_value(value) + return value if safe_header_value?(value) + + "=?base64?#{[value].pack("m0")}?=" + end + + def safe_header_value?(value) + value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } && + (value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) && + !(value.start_with?("=?base64?") && value.end_with?("?=")) + end + # Drives the OAuth orchestrator on a 401 from the MCP endpoint. # The `WWW-Authenticate` header (when present) supplies the `resource_metadata` # URL and an optional `scope` challenge per RFC 9728 Section 5.1. diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index c1d6c4a..6b33f05 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -123,6 +123,238 @@ module MCP custom_client.send_request(request: request) end + def test_mcp_method_and_name_headers_for_tools_call + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "get_weather", arguments: { city: "Tokyo" } }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_falls_back_to_uri_for_resources_read + request = { + jsonrpc: "2.0", + id: "test_id", + method: "resources/read", + params: { uri: "file:///README.md" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "resources/read", "Mcp-Name" => "file:///README.md" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_and_name_headers_for_prompts_get + request = { + jsonrpc: "2.0", + id: "test_id", + method: "prompts/get", + params: { name: "greeting" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "prompts/get", "Mcp-Name" => "greeting" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_without_name_when_params_lack_name_and_uri + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/list" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_unsafe + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "café" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?Y2Fmw6k=?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_for_notification_without_id + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "notifications/initialized" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 202, + headers: { "Content-Type" => "application/json" }, + body: "", + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_for_initialize_without_params + request = { + jsonrpc: "2.0", + id: "test_id", + method: "initialize", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "initialize" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_with_string_keyed_params + request = { + "jsonrpc" => "2.0", + "id" => "test_id", + "method" => "tools/call", + "params" => { "name" => "get_weather" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_value_has_surrounding_whitespace + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: " padded " }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?IHBhZGRlZCA=?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_value_has_crlf + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "evil\r\nX-Injected: 1" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?ZXZpbA0KWC1JbmplY3RlZDogMQ==?=" }, + ) do |req| + !req.headers.key?("X-Injected") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_re_encodes_value_matching_base64_sentinel + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "=?base64?literal?=" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_without_name_for_non_hash_params + request = { + jsonrpc: "2.0", + id: "test_id", + method: "custom/method", + params: ["positional"], + } + + stub_request(:post, url).with(headers: { "Mcp-Method" => "custom/method" }) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + def test_send_request_returns_faraday_response request = { jsonrpc: "2.0", From d6a368a54f1479afb4554942ab1e741016e27ae8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 20 Jun 2026 17:10:46 +0900 Subject: [PATCH 121/247] Require calling `MCP::Client#connect` before sending requests on stdio transport ## Motivation and Context The MCP specification requires an `initialize` request followed by `notifications/initialized` before any other interaction. Issue #334 makes that handshake explicit on the stdio client, matching the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`). The change was staged to avoid a hard break. Phase 1 (#336) added `MCP::Client::Stdio#connect` while keeping an implicit-init compatibility shim on the first `send_request`. Phase 2 (#338) emitted a deprecation warning when that shim ran. The warning has shipped since 0.16.0, so this final phase removes the shim. ## How Has This Been Tested? `send_request` now raises a `RuntimeError` when `connect` has not been called, and no longer auto-starts the subprocess. `connect` is the sole entry point that spawns the process and performs the handshake, which avoids leaving an orphaned subprocess behind. The stdio test suite was reworked to call `connect` explicitly, a regression test was added for the new guard, and `rake test` passes (1145 runs, 0 failures). ## Breaking Changes Stdio clients that send requests without calling `MCP::Client#connect` now raise a `RuntimeError` instead of silently initializing. Call `client.connect` before the first request. Closes #334 --- lib/mcp/client/stdio.rb | 10 +- test/mcp/client/stdio_test.rb | 238 +++++----------------------------- 2 files changed, 34 insertions(+), 214 deletions(-) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 222add2..a1fe8b4 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -128,9 +128,7 @@ module MCP @server_info end - # Returns true once `connect` (or the implicit handshake on the first - # `send_request`) has completed. Returns false before the handshake - # and after `close`. + # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`. def connected? @initialized end @@ -140,11 +138,7 @@ module MCP # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`, # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request. def send_request(request:) - start unless @started - unless @initialized - warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1) - connect - end + raise "MCP::Client#connect must be called before sending requests." unless @initialized @write_mutex.synchronize do write_message(request) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 3516f4e..9807a1b 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -9,8 +9,17 @@ require "mcp/client/tool" module MCP class Client class StdioTest < Minitest::Test - IMPLICIT_CONNECT_DEPRECATION_WARNING = - /Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated\. Use `MCP::Client#connect` before sending requests instead\./.freeze + def test_send_request_raises_when_connect_not_called + Open3.expects(:popen3).never + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + error = assert_raises(RuntimeError) do + transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + end + + assert_equal("MCP::Client#connect must be called before sending requests.", error.message) + end def test_send_request_starts_process_and_returns_response stdin_read, stdin_write = IO.pipe @@ -59,10 +68,8 @@ module MCP stdout_write.flush end - response = nil - assert_implicit_connect_deprecation_warning do - response = transport.send_request(request: request) - end + transport.connect + response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_equal(1, response.dig("result", "tools").size) @@ -76,73 +83,6 @@ module MCP stderr_read.close end - def test_send_request_initializes_session_on_first_call - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - received_methods = [] - - server_thread = Thread.new do - # Read initialize request - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - received_methods << init_request["method"] - - init_response = { - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0.0" }, - }, - } - stdout_write.puts(JSON.generate(init_response)) - stdout_write.flush - - # Read initialized notification - notification_line = stdin_read.gets - notification = JSON.parse(notification_line) - received_methods << notification["method"] - - # Read tools/list request - tools_line = stdin_read.gets - tools_request = JSON.parse(tools_line) - received_methods << tools_request["method"] - - tools_response = { - jsonrpc: "2.0", - id: tools_request["id"], - result: { tools: [] }, - } - stdout_write.puts(JSON.generate(tools_response)) - stdout_write.flush - end - - assert_implicit_connect_deprecation_warning do - transport.send_request(request: request) - end - - assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - def test_send_request_skips_notifications stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -193,10 +133,8 @@ module MCP stdout_write.flush end - response = nil - assert_implicit_connect_deprecation_warning do - response = transport.send_request(request: request) - end + transport.connect + response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_empty(response.dig("result", "tools")) @@ -223,17 +161,8 @@ module MCP transport = Stdio.new(command: "ruby", args: ["server.rb"]) transport.start - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end + error = assert_raises(RequestHandlerError) do + transport.connect end assert_equal("Server process has exited", error.message) @@ -283,11 +212,9 @@ module MCP stdout_write.close end - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) end assert_equal("Server process closed stdout unexpectedly", error.message) @@ -341,7 +268,7 @@ module MCP stderr_write.close end - def test_send_request_skips_initialization_on_second_call + def test_multiple_send_requests_do_not_reinitialize stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe stderr_read, _ = IO.pipe @@ -398,9 +325,8 @@ module MCP stdout_write.flush end - assert_implicit_connect_deprecation_warning do - transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) - end + transport.connect + transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) assert_equal( @@ -464,11 +390,9 @@ module MCP stdout_write.flush end - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) end assert_equal("Failed to parse server response", error.message) @@ -482,50 +406,6 @@ module MCP stdout_write.close end - def test_send_request_raises_error_when_initialization_fails - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - server_thread = Thread.new do - # Read initialize request and return an error - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - error: { code: -32600, message: "Invalid Request", data: "Unsupported protocol version" }, - })) - stdout_write.flush - end - - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end - end - - assert_equal("Server initialization failed: Invalid Request", error.message) - assert_equal(:internal_error, error.error_type) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - def test_close_kills_process_on_timeout stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -589,11 +469,9 @@ module MCP stdin_read.gets end - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) end assert_equal("Timed out waiting for server response", error.message) @@ -644,10 +522,9 @@ module MCP stdout_write.flush end - # Complete handshake with a successful request - assert_implicit_connect_deprecation_warning do - transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) - end + transport.connect + # Complete a successful request before breaking the pipe. + transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) server_thread.join # Now close stdin to simulate broken pipe @@ -713,49 +590,6 @@ module MCP assert_instance_of(Errno::ENOENT, error.original_error) end - def test_send_request_raises_error_for_missing_result - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - server_thread = Thread.new do - # Read initialize request and return a response without result - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - })) - stdout_write.flush - end - - error = nil - assert_implicit_connect_deprecation_warning do - error = assert_raises(RequestHandlerError) do - transport.send_request(request: request) - end - end - - assert_equal("Server initialization failed: missing result in response", error.message) - assert_equal(:internal_error, error.error_type) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - def test_connect_performs_initialize_handshake_explicitly stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1343,14 +1177,6 @@ module MCP private - def assert_implicit_connect_deprecation_warning(&block) - original_verbose = $VERBOSE - $VERBOSE = false - assert_output(nil, IMPLICIT_CONNECT_DEPRECATION_WARNING, &block) - ensure - $VERBOSE = original_verbose - end - def stub_successful_connect stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe From f739a4b8e821f55abf60ca75b4c576d03db31989 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 27 Jun 2026 02:49:58 +0900 Subject: [PATCH 122/247] Release 0.22.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 16 ++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21612c..618f767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.22.0] - 2026-06-27 + +### Added + +- Add `audience` role validation for `MCP::Annotations` per MCP specification (#422) +- Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification (#423) +- Support client-side `notifications/cancelled` per MCP specification (#425) + +### Changed + +- Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106 (#417) + +### Fixed + +- Fix a `SyntaxError` on Ruby 2.7.0 caused by arguments forwarding syntax (#419) + ## [0.21.0] - 2026-06-20 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 2eddb8a..d0fe33f 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.21.0" + VERSION = "0.22.0" end From 69bfe436b7793c4a5b16c1d7a1fdc2954d4c5d48 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sat, 27 Jun 2026 14:01:21 +0200 Subject: [PATCH 123/247] docs: annotate Roots, Sampling, and Logging as deprecated per SEP-2577 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the advisory YARD @deprecated annotations out of #406 so the documentation can land ahead of the 2026-07-28 protocol release. Per maintainer feedback, this drops the protocol-version-gated runtime Kernel.warn behavior and the new 2026-07-28 supported protocol version, leaving only the @deprecated notes for the Roots, Sampling, and Logging APIs deprecated by SEP-2577. Refs #390 Co-authored-by: 🦞 Qlaw --- CHANGELOG.md | 4 ++++ lib/mcp/server.rb | 7 +++++++ lib/mcp/server_context.rb | 10 ++++++++++ lib/mcp/server_session.rb | 10 ++++++++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618f767..7cff0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Deprecated + +- Annotate Roots, Sampling, and Logging APIs as deprecated per SEP-2577 (#429) + ## [0.22.0] - 2026-06-27 ### Added diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 312de4b..5ee2ba5 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -256,6 +256,9 @@ module MCP report_exception(e, { notification: "resources_list_changed" }) end + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil) return unless @transport return unless logging_message_notification&.should_notify?(level) @@ -272,6 +275,10 @@ module MCP # Called when a client notifies the server that its filesystem roots have changed. # # @yield [params] The notification params (typically `nil`). + # @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 roots_list_changed_handler(&block) @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 9873984..92b1abd 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -35,6 +35,9 @@ module MCP # @param data [Object] The log data to send. # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`). # @param logger [String, nil] Logger name. + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil) return unless @notification_target @@ -51,6 +54,10 @@ module MCP end # Delegates to the session so the request is scoped to the originating client. + # @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 if @notification_target.respond_to?(:list_roots) @notification_target.list_roots(related_request_id: @related_request_id) @@ -84,6 +91,9 @@ module MCP # 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. + # @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(**kwargs) if @notification_target.respond_to?(:create_sampling_message) @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 307217f..502c616 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -98,6 +98,10 @@ module MCP end # Sends a `roots/list` request scoped to this session. + # @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) unless client_capabilities&.dig(:roots) raise "Client does not support roots." @@ -115,6 +119,9 @@ module MCP end # Sends a `sampling/createMessage` request scoped to this session. + # @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) params = @server.build_sampling_params(client_capabilities, **kwargs) send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id) @@ -188,6 +195,9 @@ module MCP end # Sends a log message notification to this session only. + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil, related_request_id: nil) effective_logging = @logging_message_notification || @server.logging_message_notification return unless effective_logging&.should_notify?(level) From cb0eb3892b8ece54f4e86a8d45c30e71eee304b5 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 13 Jun 2026 00:41:05 +0900 Subject: [PATCH 124/247] Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport ## Motivation and Context Resolves the `sse-retry` client conformance scenario (SEP-1699, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699). Per SEP-1699, a server may close a request's SSE stream right after a priming event (an event carrying an `id:`) without delivering the response, expecting the client to treat the graceful close like a network failure: wait the server-specified `retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID` so the server can replay the pending response on the resumed stream. The Ruby client previously read the entire SSE body to EOF and parsed it afterward, so it had no notion of per-event state (`id:`, `retry:`) or reconnection, and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`). `MCP::Client::HTTP#send_request` now consumes responses incrementally via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser` as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream` tracks the last received event id, the `retry:` reconnection delay, and the awaited JSON-RPC response; once the response arrives, a held-open stream is abandoned via an internal control-flow exception since servers may never close it. When a stream closes gracefully after a priming event but before the response, the client sleeps for the `retry:` interval (default 1000ms when the server sent none), then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`, for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport` (`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when a priming event was received and no response has arrived. The public behavior of `send_request` is unchanged: same return values, same error mapping, and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter, a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body` (adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks (Faraday < 2.1, which invokes `on_data` without `env`). The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool, and the scenario is removed from the expected failures baseline. ## How Has This Been Tested? New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait, the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID` advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks (JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks when `on_data` receives no `env`) ## Breaking Changes None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF, and reconnection only activates when the server opts in by sending a priming event. --- conformance/client.rb | 9 ++ conformance/expected_failures.yml | 2 - lib/mcp/client/http.rb | 227 +++++++++++++++++++++++++----- test/mcp/client/http_test.rb | 165 +++++++++++++++++++++- 4 files changed, 366 insertions(+), 37 deletions(-) diff --git a/conformance/client.rb b/conformance/client.rb index c9f3ec5..7b7ddea 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -135,6 +135,15 @@ when "tools_call" add_numbers = tools.find { |t| t.name == "add_numbers" } abort("Tool add_numbers not found") unless add_numbers client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) +when "sse-retry" + # SEP-1699: the server closes the tools/call SSE stream right after a priming event. + # The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`, + # and receives the tool result on the resumed stream; the harness verifies the reconnect, + # its timing, and the header. + tools = client.tools + test_reconnection = tools.find { |t| t.name == "test_reconnection" } + abort("Tool test_reconnection not found") unless test_reconnection + client.call_tool(tool: test_reconnection, arguments: {}) when %r|\Aauth/| # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) # are observed by the conformance server during `connect` and the subsequent request below. diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 1ab4cc4..cb397ba 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,7 +1,5 @@ server: [] client: - # TODO: SSE reconnection not implemented in Ruby client. - - sse-retry # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 3a2a2ac..92aac79 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -8,16 +8,24 @@ require_relative "../version" module MCP class Client - # TODO: HTTP GET for SSE streaming is not yet implemented. + # TODO: A standalone HTTP GET listening stream for server-initiated messages is not yet implemented; + # GET is currently used only to resume a request's SSE stream after a disconnect. # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server - # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery class HTTP ACCEPT_HEADER = "application/json, text/event-stream" + SSE_ACCEPT_HEADER = "text/event-stream" SESSION_ID_HEADER = "Mcp-Session-Id" PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" METHOD_HEADER = "Mcp-Method" NAME_HEADER = "Mcp-Name" + LAST_EVENT_ID_HEADER = "Last-Event-ID" + + # SEP-1699 reconnection tuning: the SSE `retry:` field from the server takes precedence; + # this default applies when the server sent none. Both values match the Python SDK + # (`DEFAULT_RECONNECTION_DELAY_MS`, `MAX_RECONNECTION_ATTEMPTS`); the TypeScript SDK uses + # an exponential backoff that the `retry:` field likewise overrides. + DEFAULT_RECONNECTION_DELAY_MS = 1000 + MAX_RECONNECTION_ATTEMPTS = 2 # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host @@ -54,6 +62,101 @@ module MCP end end + # Internal control-flow signal raised inside the streaming callback to stop reading + # an SSE stream once the awaited JSON-RPC response has arrived; servers may hold + # a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on. + class StreamAbort < StandardError; end + private_constant :StreamAbort + + # Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams: + # the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`), + # and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path. + class SSEStream + attr_reader :buffer, :response, :last_event_id, :retry_ms + attr_accessor :abortable + + def initialize(abortable:) + @abortable = abortable + @buffer = +"" + @parser = nil + @response = nil + @last_event_id = nil + @retry_ms = nil + end + + # Whether the server sent a SEP-1699 priming event (any event carrying an id), + # which marks the stream as resumable after a graceful close. + def primed? + !@last_event_id.nil? + end + + # Faraday `on_data` streaming callback. SSE chunks are parsed incrementally; + # anything else (JSON bodies) accumulates in `buffer`. + def on_data + proc do |chunk, _received_bytes, env| + if event_stream?(env) + feed(chunk) + raise StreamAbort if @abortable && @response + else + @buffer << chunk + end + end + end + + # A fresh parser for a new HTTP connection (reconnection GET), + # so a partial line from the previous stream cannot corrupt the next one. + def reset_parser! + @parser = nil + end + + # Parses an SSE body that was delivered outside the streaming callback: + # Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer` + # (consumed here so they are not parsed twice), and adapters without `on_data` support + # yield the whole body only via `fallback_body` (the Faraday `response.body`). + def ingest_pending!(fallback_body) + text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1) + feed(text) if text.is_a?(String) && !text.empty? + end + + private + + def event_stream?(env) + headers = env&.response_headers + content_type = headers && (headers["content-type"] || headers["Content-Type"]) + !!content_type&.include?("text/event-stream") + end + + def feed(chunk) + parser.feed(chunk) do |_type, data, id, reconnection_time| + @last_event_id = id if id && !id.empty? + @retry_ms = reconnection_time if reconnection_time + next if data.nil? || data.empty? + + begin + parsed = JSON.parse(data) + rescue JSON::ParserError + next + end + + if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error")) + @response ||= parsed + end + end + end + + def parser + @parser ||= begin + require "event_stream_parser" + EventStreamParser::Parser.new + rescue LoadError + raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \ + "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \ + "See https://rubygems.org/gems/event_stream_parser for more details." + end + end + end + private_constant :SSEStream + attr_reader :url, :session_id, :protocol_version, :server_info, :oauth def initialize(url:, headers: {}, oauth: nil, &block) @@ -199,11 +302,22 @@ module MCP step_up_retried = false begin + # The response is consumed incrementally so that an SSE stream the server holds open + # (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF + # so the response object (and its `Mcp-Session-Id` header) is always available for capture. + stream = SSEStream.new(abortable: method.to_s != MCP::Methods::INITIALIZE) yield if block_given? - response = client.post("", request, session_headers.merge(request_metadata_headers(method, params))) - body = parse_response_body(response, method, params) + response = begin + client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req| + req.options.on_data = stream.on_data + end + rescue StreamAbort + nil + end - capture_session_info(method, response, body) + body = resolve_response_body(stream, response, method, params) + + capture_session_info(method, response, body) if response body rescue Faraday::BadRequestError => e @@ -536,15 +650,11 @@ module MCP "See https://rubygems.org/gems/faraday for more details." end - def require_event_stream_parser! - require "event_stream_parser" - rescue LoadError - raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \ - "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \ - "See https://rubygems.org/gems/event_stream_parser for more details." - end + # Determines the logical JSON-RPC body of the exchange after the POST stream finished + # (or was aborted because the response already arrived). + def resolve_response_body(stream, response, method, params) + return stream.response if stream.response - def parse_response_body(response, method, params) # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected. # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server return if response.status == 202 @@ -552,9 +662,29 @@ module MCP content_type = response.headers["Content-Type"] if content_type&.include?("text/event-stream") - parse_sse_response(response.body, method, params) + # Nothing was parsed during streaming: either the adapter does not support `on_data` + # (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data` + # (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body. + unless stream.primed? + stream.ingest_pending!(response.body) + return stream.response if stream.response + end + + # SEP-1699: a graceful close after a priming event (an event with an id) but before + # the response means "reconnect via GET to resume". + return await_response_after_disconnect(stream, method, params) if stream.primed? + + raise RequestHandlerError.new( + "No valid JSON-RPC response found in SSE stream", + { method: method, params: params }, + error_type: :parse_error, + ) elsif content_type&.include?("application/json") - response.body + return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty? + + # Adapters without `on_data` support deliver the body via `response.body`, + # already parsed by the json response middleware. + response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body else raise RequestHandlerError.new( "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.", @@ -564,30 +694,59 @@ module MCP end end - def parse_sse_response(body, method, params) - require_event_stream_parser! - - json_rpc_response = nil - parser = EventStreamParser::Parser.new - parser.feed(body.to_s) do |_type, data, _id| - next if data.empty? - - begin - parsed = JSON.parse(data) - json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error")) - rescue JSON::ParserError - next - end - end - - return json_rpc_response if json_rpc_response + def parse_json_buffer(buffer, method, params) + return if buffer.empty? + JSON.parse(buffer) + rescue JSON::ParserError => e raise RequestHandlerError.new( - "No valid JSON-RPC response found in SSE stream", + "Failed to parse JSON response: #{e.message}", { method: method, params: params }, error_type: :parse_error, ) end + + # SEP-1699 resumability: the server closed the SSE stream after a priming event + # without delivering the response. Treat the graceful close like a network failure: + # wait the server-specified `retry:` interval (default 1000ms), then reconnect with + # a GET carrying `Last-Event-ID` so the server can replay the pending response on + # the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport` + # reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap). + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 + def await_response_after_disconnect(stream, method, params) + stream.abortable = true + + MAX_RECONNECTION_ATTEMPTS.times do + sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) + stream.reset_parser! + + reconnect_response = begin + client.get("") do |req| + req.headers.update(session_headers) + req.headers["Accept"] = SSE_ACCEPT_HEADER + req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id + req.options.on_data = stream.on_data + end + rescue StreamAbort + # The awaited response arrived on the reconnected stream. + nil + end + + # Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`. + if reconnect_response && stream.response.nil? + stream.ingest_pending!(reconnect_response.body) + end + + return stream.response if stream.response + end + + raise RequestHandlerError.new( + "Server closed the SSE stream without a response for #{method} " \ + "after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts", + { method: method, params: params }, + error_type: :internal_error, + ) + end end end end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 6b33f05..31044b3 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -35,7 +35,7 @@ module MCP ) HTTP.any_instance.stubs(:require).with("faraday").returns(true) - HTTP.any_instance.stubs(:require).with("event_stream_parser") + HTTP.const_get(:SSEStream).any_instance.stubs(:require).with("event_stream_parser") .raises(LoadError, "cannot load such file -- event_stream_parser") error = assert_raises(LoadError) do @@ -688,6 +688,169 @@ module MCP assert_includes(error.message, "No valid JSON-RPC response found in SSE stream") assert_equal(:parse_error, error.error_type) + assert_not_requested(:get, url) + end + + def test_send_request_reconnects_with_last_event_id_after_primed_graceful_close + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 100\ndata:\n\n", + ) + + get_body = "id: event-2\nretry: 100\ndata:\n\n" \ + "event: message\nid: event-3\n" \ + 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + get_stub = stub_request(:get, url).with( + headers: { "Accept" => "text/event-stream", "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = client.send_request(request: request) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(get_stub) + + # The server-specified `retry:` interval must elapse before the reconnection GET. + assert_operator(elapsed, :>=, 0.1) + end + + def test_send_request_uses_default_reconnection_delay_when_retry_field_absent + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\ndata:\n\n", + ) + + get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_raises_after_reconnection_attempts_are_exhausted + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 10\ndata:\n\n", + ) + + first_get = stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-2\nretry: 10\ndata:\n\n", + ) + second_get = stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-2" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-3\nretry: 10\ndata:\n\n", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "after 2 reconnection attempts") + assert_equal(:internal_error, error.error_type) + assert_requested(first_get) + assert_requested(second_get) + end + + def test_send_request_parses_json_response_when_adapter_does_not_stream + # The Faraday test adapter ignores `on_data`, like adapters without + # streaming support; the body must be read from `response.body`. + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + + assert_equal({ "result" => { "tools" => [] } }, response) + end + + def test_send_request_parses_sse_response_when_adapter_does_not_stream + sse_body = "event: message\n" \ + 'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \ + "\n\n" + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "text/event-stream" }, sse_body] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + + assert_equal({ "tools" => [] }, response["result"]) + end + + def test_sse_stream_parses_buffered_chunks_when_env_is_unavailable + # Faraday < 2.1 invokes `on_data` without `env`; the content type + # cannot be detected, so SSE chunks accumulate in the buffer and are + # parsed by the `ingest_pending!` fallback. + stream = HTTP.const_get(:SSEStream).new(abortable: true) + chunk = "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{}}\n\n" + + stream.on_data.call(chunk, chunk.bytesize) + + assert_nil(stream.response) + assert_equal(chunk, stream.buffer) + + stream.ingest_pending!(nil) + + assert_equal({ "jsonrpc" => "2.0", "id" => "test_id", "result" => {} }, stream.response) + assert_empty(stream.buffer) end def test_captures_session_id_and_protocol_version_on_initialize From ba543083a7594e7892b29464b89091816446ff7a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 1 May 2026 02:19:38 +0900 Subject: [PATCH 125/247] Validate `Host` and `Origin` Headers to Prevent DNS Rebinding per MCP 2025-11-25 ## Motivation and Context MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost, to prevent DNS rebinding attacks against locally bound MCP servers. `StreamableHTTPTransport` previously validated neither `Host` nor `Origin`, so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin, invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m. The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of external middleware) and the Python SDK (`transport_security.py` disables protection by default) both ship this off for backwards compatibility, a constraint they cannot escape after 1.0. Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline: - `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`, compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`. This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`. A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one. - `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port) case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches `Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected. - `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers. A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: - a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed - a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values (`127.0.0.1`, `localhost`, `[::1]`) are allowed - an upper-cased loopback `Host` is accepted (case-insensitive matching) - a non-loopback `Host` listed in `allowed_hosts:` is allowed - a request with no `Host` header, and one with no `Origin` header, are allowed - a default-port `Origin` (`http://localhost`) matches `Host: localhost:80` - opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected - a `host:port` `allowed_hosts:` entry matches that port only - `Host` validation also applies to GET requests - `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through ## Breaking Changes `StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default. A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`, or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients are unaffected. Co-authored-by: tonghuaroot --- README.md | 21 ++ .../transports/streamable_http_transport.rb | 106 ++++++- .../streamable_http_transport_test.rb | 300 ++++++++++++++++++ 3 files changed, 426 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b986c26..bdee5e3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,27 @@ The following examples show two common integration styles in Rails. > > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> [!IMPORTANT] +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). + ##### Rails (mount) `StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4b8f8ad..8b8e2f9 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -24,7 +24,31 @@ module MCP "Connection" => "keep-alive", }.freeze - def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) + # Creates a Streamable HTTP transport that can be mounted as a Rack app. + # + # @param server [MCP::Server] the server whose requests this transport dispatches. + # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained. + # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON + # object instead of an SSE stream. + # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; `nil` + # disables expiry. Not supported in stateless mode. + # @param allowed_origins [Array, nil] extra `Origin` values accepted in addition to + # same-origin requests, for DNS rebinding protection. + # @param allowed_hosts [Array, nil] extra `Host` values accepted beyond the loopback + # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port) + # or a full `host:port`. + # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and + # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already + # validates them. + def initialize( + server, + stateless: false, + enable_json_response: false, + session_idle_timeout: nil, + allowed_origins: nil, + allowed_hosts: nil, + dns_rebinding_protection: true + ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @@ -33,6 +57,11 @@ module MCP @stateless = stateless @enable_json_response = enable_json_response @session_idle_timeout = session_idle_timeout + @dns_rebinding_protection = dns_rebinding_protection + + # Host names are case-insensitive, so the allow lists are compared down-cased. + @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze + @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} if @session_idle_timeout @@ -52,12 +81,19 @@ module MCP STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze SESSION_REAP_INTERVAL = 60 + # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is + # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. + DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze + # Rack app interface. This transport can be mounted as a Rack app. def call(env) handle_request(Rack::Request.new(env)) end def handle_request(request) + rebinding_error = validate_dns_rebinding(request) + return rebinding_error if rebinding_error + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -808,6 +844,74 @@ module MCP active end + # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost + # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default; + # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already + # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`, + # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`. + def validate_dns_rebinding(request) + return unless @dns_rebinding_protection + + validate_host(request) || validate_origin(request) + end + + # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1). + # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one. + def validate_host(request) + host = request.env["HTTP_HOST"] + return if host.nil? + + # An `allowed_hosts:` entry matches either the bare host name (any port) + # or the full `host:port` value, so both `"app.example.com"` and + # `"app.example.com:8443"` can be configured. + normalized = host.downcase + return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized) + + forbidden_response("Forbidden: Invalid Host header") + end + + # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is + # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`. + def validate_origin(request) + origin = request.env["HTTP_ORIGIN"] + return if origin.nil? + return if same_origin?(origin, request) + return if @allowed_origins.include?(origin.downcase) + + forbidden_response("Forbidden: Invalid Origin header") + end + + # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets + # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`). + def request_hostname(host) + return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[") + + host.split(":").first + end + + # Compares the `Origin` authority (host:port) against the request's own `Host`. + # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies), + # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from + # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive. + def same_origin?(origin, request) + host = request.env["HTTP_HOST"] + return false if host.nil? + + normalized = origin.downcase + default_port = normalized.start_with?("https://") ? ":443" : ":80" + authority = normalized.sub(%r{\Ahttps?://}, "") + + authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) + end + + def forbidden_response(message) + json_rpc_error_response( + status: 403, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + ) + end + def method_not_allowed_response json_rpc_error_response( status: 405, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 550e926..fe57579 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4328,6 +4328,306 @@ module MCP refute headers.frozen?, "SSE response headers should not be frozen" end + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + status, headers, body = transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"]) + ensure + transport&.close + end + + test "handle_request returns 403 for GET when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "GET", + "/", + { "HTTP_ORIGIN" => "https://evil.example.com" }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request returns 403 for DELETE when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_ORIGIN" => "https://evil.example.com", + "HTTP_MCP_SESSION_ID" => "any", + }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin is in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin header is absent (e.g. non-browser client)" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request rejects a cross-origin request by default (secure-by-default)" do + # No allowed_origins configured: a browser cross-origin Origin is still rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://anywhere.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 403, status + end + + test "handle_request allows a same-origin browser request" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "localhost:3000", + "HTTP_ORIGIN" => "http://localhost:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 200, status + end + + test "handle_request rejects a rebound Host by default" do + # DNS rebinding: a foreign Host re-pointed at the loopback server is rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, headers, body = @transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"]) + end + + test "handle_request allows loopback Host values by default" do + ["127.0.0.1:8080", "localhost:3000", "[::1]:8080"].each do |host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => host, + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status, "expected #{host} to be allowed") + ensure + transport.close + end + end + + test "handle_request matches Host case-insensitively" do + # Host names are case-insensitive, so an upper-cased loopback Host is allowed. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "LOCALHOST:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request allows a non-loopback Host listed in allowed_hosts" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + + test "handle_request matches a host:port entry in allowed_hosts exactly" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com:8443"]) + + allowed = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + rejected = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(allowed).first) + assert_equal(403, transport.handle_request(rejected).first) + ensure + transport.close + end + + test "handle_request treats a default port as same-origin" do + # `Origin: http://localhost` (no port) is same-origin with `Host: localhost:80`. + [ + ["http://localhost", "localhost:80"], + ["https://localhost", "localhost:443"], + ].each do |origin, host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match") + ensure + transport.close + end + end + + test "handle_request rejects opaque and malformed Origins" do + ["null", "file:///etc/passwd", "http://localhost:3000/path"].each do |origin| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected") + ensure + transport.close + end + end + + test "handle_request enforces Host validation on GET requests" do + request = create_rack_request( + "GET", + "/", + { "HTTP_HOST" => "evil.example.com" }, + ) + + status, = @transport.handle_request(request) + assert_equal(403, status) + end + + test "handle_request allows a request with no Host header" do + # Non-browser clients may omit Host; the rebinding vector always carries one. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request skips DNS rebinding checks when protection is disabled" do + transport = StreamableHTTPTransport.new(@server, dns_rebinding_protection: false) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + private def create_rack_request(method, path, headers, body = nil) From 14b4f7c8c76783bb8f5550e3c25a7d6e153db67e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:11:32 +0900 Subject: [PATCH 126/247] Validate the RFC 9207 `iss` Authorization Response Parameter per SEP-2468 ## Motivation and Context SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468, merged for the 2026-07-28 spec release) recommends that authorization servers include the RFC 9207 `iss` parameter in authorization responses and requires MCP clients to validate a present `iss` against the expected issuer before redeeming the authorization code. This mitigates mix-up attacks in multi-IdP setups, where an attacker-controlled authorization server could otherwise capture a code minted by an honest server. This mirrors the TypeScript SDK's approach (typescript-sdk#2272, with typescript-sdk#1957 as the reference implementation), adapted to the Ruby provider contract: - `Provider`'s `callback_handler` may now return `[code, state, iss]` in addition to the existing `[code, state]`. The 3-element form opts into validation; returning `iss = nil` asserts the caller inspected the authorization response and found no `iss`. - `Flow#run!` validates the issuer after the `state` check and before `exchange_authorization_code`, implementing the RFC 9207 Section 2.4 rules: a present `iss` must equal the AS metadata `issuer` exactly (the value `ensure_issuer_matches!` already pinned to the discovery URL); a nil `iss` from a 3-element callback is rejected when the AS metadata advertises `authorization_response_iss_parameter_supported: true`; a legacy 2-element callback skips the check, so every existing integration keeps working. On any violation the flow aborts with `AuthorizationError` before the code reaches a token endpoint. - The conformance client now returns the 3-element form, plumbing `iss` through from the redirect URL. Resolves #386. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - a matching `iss` completes the flow and persists tokens - a mismatched `iss` raises `AuthorizationError` and never contacts the token endpoint (`assert_not_requested` on the token POST) - a nil `iss` with `authorization_response_iss_parameter_supported: true` raises without contacting the token endpoint (fail closed) - a legacy 2-element callback completes even when the AS advertises `iss` support (backward compatibility) - a nil `iss` without the advertisement completes - a mismatched `iss` is rejected even without the advertisement (validate-when-present is unconditional) `bundle exec rake` (tests, RuboCop, and conformance baseline with the 3-element conformance callback) passes. ## Breaking Changes None. The 2-element `callback_handler` contract continues to work unchanged; issuer validation activates only when a callback opts in by returning the 3-element form. --- README.md | 4 +- conformance/client.rb | 4 +- lib/mcp/client/oauth/flow.rb | 46 ++++++++++++- lib/mcp/client/oauth/provider.rb | 9 ++- test/mcp/client/oauth/flow_test.rb | 105 +++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b986c26..0a05d0e 100644 --- a/README.md +++ b/README.md @@ -2118,7 +2118,9 @@ Required keyword arguments to `Provider.new`: an explicit value always wins. - `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. - `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. -- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`. +- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form + (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match + the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. Optional keyword arguments: diff --git a/conformance/client.rb b/conformance/client.rb index c9f3ec5..73565e5 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -95,7 +95,9 @@ def build_oauth_provider(context, scenario:) callback_handler = -> do query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h - [query["code"], query["state"]] + # The 3-element form opts into SEP-2468 / RFC 9207 `iss` validation; + # `query["iss"]` is nil when the authorization response carried no `iss`. + [query["code"], query["state"], query["iss"]] end MCP::Client::OAuth::Provider.new( diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 7236f71..5a5c600 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -74,13 +74,20 @@ module MCP ) @provider.redirect_handler.call(authorization_url) - code, returned_state = Array(@provider.callback_handler.call) + callback_result = Array(@provider.callback_handler.call) + code, returned_state, returned_iss = callback_result raise AuthorizationError, "Authorization callback did not return an authorization code." unless code unless states_match?(returned_state, state) raise AuthorizationError, "OAuth state mismatch (CSRF protection)." end + validate_authorization_response_issuer!( + as_metadata: as_metadata, + iss: returned_iss, + iss_provided: callback_result.length >= 3, + ) + tokens = exchange_authorization_code( as_metadata: as_metadata, client_info: client_info, @@ -534,6 +541,43 @@ module MCP raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}." end + # Validates the RFC 9207 `iss` authorization response parameter against the issuer of the authorization server + # the flow is talking to, per SEP-2468 (mix-up attack mitigation in multi-IdP setups): + # + # - When the callback supplied a non-empty `iss`, it MUST equal the AS metadata `issuer` exactly (simple string comparison, + # no normalization, per RFC 9207 Section 2.4); on mismatch the flow aborts before the authorization code is ever sent to + # a token endpoint. + # - When the callback returned a 3-element `[code, state, iss]` whose `iss` is nil (asserting it inspected the authorization response + # and found no `iss`) and the AS metadata advertises `authorization_response_iss_parameter_supported: true`, the missing parameter + # is treated as an attack per RFC 9207 Section 2.4 and the flow aborts. + # - A legacy 2-element `[code, state]` callback skips the check: the caller never looked for `iss`, so its absence carries no signal. + # + # `as_metadata["issuer"]` has already been byte-compared against the discovery URL by `ensure_issuer_matches!`, + # so it is the RFC 9207 anchor value. `iss` is not a secret; a plain `==` suffices. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468 + # - https://www.rfc-editor.org/rfc/rfc9207 + def validate_authorization_response_issuer!(as_metadata:, iss:, iss_provided:) + expected = as_metadata["issuer"] + + if iss && !iss.to_s.empty? + return if iss.to_s == expected.to_s + + raise AuthorizationError, + "Authorization response `iss` does not match the authorization server issuer " \ + "(expected #{expected.inspect}, got #{iss.inspect}) (RFC 9207); " \ + "refusing to exchange the authorization code." + end + + return unless iss_provided + return unless as_metadata["authorization_response_iss_parameter_supported"] == true + + raise AuthorizationError, + "Authorization server advertises `authorization_response_iss_parameter_supported` but " \ + "the authorization response carried no `iss` parameter (RFC 9207 Section 2.4); " \ + "refusing to exchange the authorization code." + end + # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery # of the expected value. # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7 diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 4864ae4..c8fe55c 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -21,8 +21,13 @@ module MCP # - `redirect_handler` - Callable invoked with the fully-built authorization # URL (a `URI`). Implementations typically open the user's browser. # - `callback_handler` - Callable invoked after `redirect_handler`. Returns - # `[code, state]` where `code` is the authorization code and `state` is - # the `state` parameter received on the redirect URI. + # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code, + # `state` is the `state` parameter received on the redirect URI, and `iss` is + # the RFC 9207 `iss` parameter (or nil when the authorization response carried none). + # Returning the 3-element form opts into SEP-2468 issuer validation: a present `iss` + # must match the authorization server's issuer, and a nil `iss` is + # rejected when the AS advertises `authorization_response_iss_parameter_supported`. + # The 2-element form skips the check for backward compatibility. # # Optional keyword arguments: # - `scope` - String of space-separated scopes to request when the server's diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index b92b9f9..0891fd2 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -985,6 +985,111 @@ module MCP assert_match(/client_id/i, error.message) end + # Builds a provider whose callback returns the given `iss` shape: + # `:omitted` keeps the legacy 2-element `[code, state]` form, any other value (including nil) returns + # the 3-element `[code, state, iss]` form that opts into SEP-2468 / RFC 9207 issuer validation. + def build_iss_validation_provider(callback_iss: :omitted) + state_holder = {} + callback_handler = if callback_iss == :omitted + -> { ["test-auth-code", state_holder[:state]] } + else + -> { ["test-auth-code", state_holder[:state], callback_iss] } + end + + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: callback_handler, + ) + end + + # Re-stubs the AS metadata with `authorization_response_iss_parameter_supported: true`. + def stub_as_metadata_with_iss_support + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + authorization_response_iss_parameter_supported: true, + ), + ) + end + + def test_run_completes_when_authorization_response_iss_matches_issuer + provider = build_iss_validation_provider(callback_iss: @auth_base) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + end + + def test_run_aborts_before_code_exchange_when_authorization_response_iss_mismatches + provider = build_iss_validation_provider(callback_iss: "https://evil.example.com") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # Per RFC 9207, the client MUST NOT send the authorization code to a token endpoint after an `iss` mismatch. + assert_match(/`iss` does not match/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_aborts_when_iss_is_missing_but_as_advertises_iss_support + stub_as_metadata_with_iss_support + provider = build_iss_validation_provider(callback_iss: nil) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/carried no `iss`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_completes_with_legacy_two_element_callback_even_when_as_advertises_iss_support + stub_as_metadata_with_iss_support + provider = build_iss_validation_provider + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_completes_when_iss_is_missing_and_as_does_not_advertise_iss_support + provider = build_iss_validation_provider(callback_iss: nil) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_validates_present_iss_even_when_as_does_not_advertise_iss_support + provider = build_iss_validation_provider(callback_iss: "https://evil.example.com") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_run_skips_dcr_when_client_information_already_stored stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) From 4327fc83731a695beca9da3f0b0109e7dd905138 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 14 Jun 2026 00:23:50 +0900 Subject: [PATCH 127/247] Support `private_key_jwt` Client Authentication for the client_credentials Grant ## Motivation and Context The MCP `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies two client authentication methods for the `client_credentials` grant: `client_secret_basic` and the RECOMMENDED `private_key_jwt` (RFC 7523 Section 2.2 JWT client assertion). The previous commit added the grant with secret-based authentication; this adds the JWT method, which is what the `auth/client-credentials-jwt` conformance scenario exercises. This mirrors the merged implementations in the TypeScript SDK (`PrivateKeyJwtProvider`, typescript-sdk#1157) and the Python SDK (`PrivateKeyJWTOAuthProvider` / `SignedJWTParameters`, python-sdk#1663), adapted to this SDK's provider design: - New `MCP::Client::OAuth::JWTClientAssertion` builds the RFC 7523 compact JWS with openssl, keeping the SDK free of a JWT gem dependency (the TypeScript and Python SDKs use jose and PyJWT). Claims follow SEP-1046: `iss` and `sub` carry the client_id, `aud` carries the authorization server's issuer identifier, plus `exp` (300-second lifetime, matching both SDKs), `iat`, and a unique `jti`. ES256 and RS256 are supported; ES256 signatures are converted from OpenSSL's ASN.1 DER form to the raw 64-byte `r || s` encoding JWS requires (RFC 7518 Section 3.4). - `ClientCredentialsProvider` accepts `token_endpoint_auth_method: "private_key_jwt"` with `private_key:` (PEM string or `OpenSSL::PKey::PKey`) and a required `signing_algorithm:` (no default, so an algorithm mismatch with the server's `token_endpoint_auth_signing_alg_values_supported` fails loudly at construction; both reference SDKs also take the algorithm explicitly). The key stays on the provider and is never written to `storage`; a throwaway assertion is signed at construction to fail fast on an unparseable key or a key/algorithm mismatch. - `Flow#post_to_token_endpoint` sends `client_assertion_type` and a freshly signed `client_assertion` for `private_key_jwt`, using the `ensure_issuer_matches!`-validated issuer as the audience, and omits `client_id` from the body per SEP-1046 (identity travels in the assertion's claims). - The conformance client builds a `private_key_jwt` provider when the harness injects `private_key_pem`, defaulting the algorithm to ES256 like the TypeScript and Python conformance clients, and `auth/client-credentials-jwt` is removed from `conformance/expected_failures.yml`. Scope notes: only ES256 and RS256 are supported for now (the TypeScript and Python SDKs accept whatever their JWT libraries support); adding further algorithms is additive. There is also no injection point yet for externally signed assertions (the TypeScript SDK's `StaticPrivateKeyJwtProvider` and the Python SDK's `assertion_provider` callback cover KMS/HSM-held keys); that is a separate follow-up. ## How Has This Been Tested? New `test/mcp/client/oauth/jwt_client_assertion_test.rb` covers the JWS construction: header and RFC 7523 claims, unpadded base64url segments, ES256 signature round-trip verification (raw -> DER -> `OpenSSL::PKey::EC#verify`), RS256 verification, PEM and parsed-key input, lifetime override, unique `jti` per assertion, and rejection of unsupported algorithms, key/algorithm mismatches (including a P-384 key with ES256), unparseable PEM, and public-only keys. New tests in `test/mcp/client/oauth/client_credentials_provider_test.rb` cover the provider surface: `private_key_jwt` construction, the key and secret never reaching `client_information`, required `private_key` / `signing_algorithm`, rejection of a simultaneous `client_secret`, fail-fast on key/algorithm mismatch, and `client_assertion(audience:)` producing a JWT with the expected claims. A new test in `test/mcp/client/oauth/flow_test.rb` runs the full grant against WebMock stubs and asserts the token request carries `grant_type=client_credentials`, the RFC 7523 `client_assertion_type`, an assertion whose `iss`/`sub` are the client_id and whose `aud` is the issuer, the RFC 8707 `resource`, and neither `client_id` nor any secret in the body or headers. `bundle exec rake` passes; the `auth/client-credentials-jwt` conformance scenario passes 7/7 checks and the baseline check passes with its expected-failure entry removed. ## Breaking Changes None. `client_secret:` becomes optional on `ClientCredentialsProvider` only in the signature; it is still required for the secret-based methods, and all existing construction paths behave unchanged. --- conformance/client.rb | 24 ++- conformance/expected_failures.yml | 1 - lib/mcp/client/oauth.rb | 1 + .../oauth/client_credentials_provider.rb | 86 +++++++-- lib/mcp/client/oauth/flow.rb | 21 ++- lib/mcp/client/oauth/jwt_client_assertion.rb | 128 ++++++++++++++ .../oauth/client_credentials_provider_test.rb | 92 ++++++++++ test/mcp/client/oauth/flow_test.rb | 51 ++++++ .../client/oauth/jwt_client_assertion_test.rb | 166 ++++++++++++++++++ 9 files changed, 548 insertions(+), 22 deletions(-) create mode 100644 lib/mcp/client/oauth/jwt_client_assertion.rb create mode 100644 test/mcp/client/oauth/jwt_client_assertion_test.rb diff --git a/conformance/client.rb b/conformance/client.rb index c9f3ec5..8234e46 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -66,13 +66,25 @@ def storage_for(context) end # Builds a `client_credentials`-only provider (machine-to-machine, no redirect). -# The pre-registered credentials are injected by the harness via context. +# The pre-registered credentials are injected by the harness via context: +# a shared secret for the basic scenario, or a PEM private key plus signing algorithm +# (default ES256, matching the TypeScript and Python conformance clients) +# for the `private_key_jwt` scenario. def build_client_credentials_provider(context) - MCP::Client::OAuth::ClientCredentialsProvider.new( - client_id: context["client_id"], - client_secret: context["client_secret"], - token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", - ) + if context["private_key_pem"] + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + token_endpoint_auth_method: "private_key_jwt", + private_key: context["private_key_pem"], + signing_algorithm: context["signing_algorithm"] || "ES256", + ) + else + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end end # Builds an OAuth provider that drives the authorization code + PKCE + DCR flow diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 1ab4cc4..19d3cad 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -5,5 +5,4 @@ client: # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/client-credentials-jwt - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index fc33c17..6d1ab3f 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -5,6 +5,7 @@ require_relative "oauth/flow" require_relative "oauth/in_memory_storage" require_relative "oauth/pkce" require_relative "oauth/storage_backed_provider" +require_relative "oauth/jwt_client_assertion" require_relative "oauth/provider" require_relative "oauth/client_credentials_provider" diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index fa01d5c..688ff17 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -15,14 +15,26 @@ module MCP # Required keyword arguments: # # - `client_id` - String identifying the pre-registered confidential client. - # - `client_secret` - String shared secret. The `client_credentials` grant - # is for confidential clients, so a credential is mandatory. + # - `client_secret` - String shared secret for the `client_secret_basic` / + # `client_secret_post` methods. The `client_credentials` grant is for + # confidential clients, so a credential is mandatory; with + # `private_key_jwt` the credential is the `private_key` instead. # # Optional keyword arguments: # - # - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or - # `"client_secret_post"`. `"none"` is rejected: an unauthenticated + # - `token_endpoint_auth_method` - `"client_secret_basic"` (default), + # `"client_secret_post"`, or `"private_key_jwt"` (RFC 7523 JWT client + # assertion, per the `io.modelcontextprotocol/oauth-client-credentials` + # extension / SEP-1046). `"none"` is rejected: an unauthenticated # `client_credentials` request is meaningless. + # - `private_key` - PEM string (or `OpenSSL::PKey::PKey`) used to sign + # the JWT client assertion. Required with `private_key_jwt`. + # The key is held on the provider and never written to `storage`. + # - `signing_algorithm` - `"ES256"` or `"RS256"`. Required with + # `private_key_jwt`; there is no default, so a mismatch with + # the server's `token_endpoint_auth_signing_alg_values_supported` fails + # loudly at construction instead of as a 401 (the TypeScript and Python SDKs + # also take the algorithm as an explicit option). # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, @@ -37,14 +49,16 @@ module MCP # missing or the requested client authentication method cannot carry them. class InvalidCredentialsError < ArgumentError; end - SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze + SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze attr_reader :scope, :storage def initialize( client_id:, - client_secret:, + client_secret: nil, token_endpoint_auth_method: "client_secret_basic", + private_key: nil, + signing_algorithm: nil, scope: nil, storage: nil ) @@ -59,18 +73,37 @@ module MCP "client_credentials request is not allowed." end - if blank?(client_secret) - raise InvalidCredentialsError, - "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method } + + if token_endpoint_auth_method == "private_key_jwt" + validate_private_key_jwt_arguments!( + client_secret: client_secret, + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + + # Fail fast on an unparseable key or a key/algorithm mismatch by + # signing a throwaway assertion now rather than at token time. + JWTClientAssertion.generate( + client_id: client_id, + audience: "urn:mcp:credential-validation", + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + else + if blank?(client_secret) + raise InvalidCredentialsError, "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + end + + client_information["client_secret"] = client_secret end + @client_id = client_id + @private_key = private_key + @signing_algorithm = signing_algorithm @scope = scope @storage = storage || InMemoryStorage.new - @storage.save_client_information( - "client_id" => client_id, - "client_secret" => client_secret, - "token_endpoint_auth_method" => token_endpoint_auth_method, - ) + @storage.save_client_information(client_information) end # See `Provider#authorization_flow`. @@ -78,8 +111,33 @@ module MCP :client_credentials end + # Returns a freshly signed RFC 7523 JWT client assertion for the `private_key_jwt` method. + # `audience` is the authorization server's issuer identifier. Called by `Flow#post_to_token_endpoint`. + def client_assertion(audience:) + JWTClientAssertion.generate( + client_id: @client_id, + audience: audience, + private_key: @private_key, + signing_algorithm: @signing_algorithm, + ) + end + private + def validate_private_key_jwt_arguments!(client_secret:, private_key:, signing_algorithm:) + unless client_secret.nil? + raise InvalidCredentialsError, "client_secret must not be set with private_key_jwt; the private key is the credential." + end + + if private_key.nil? + raise InvalidCredentialsError, "private_key is required for the client_credentials grant with private_key_jwt." + end + + return unless blank?(signing_algorithm) + + raise InvalidCredentialsError, "signing_algorithm is required with private_key_jwt (one of #{JWTClientAssertion::SUPPORTED_ALGORITHMS.inspect})." + end + def blank?(value) value.nil? || (value.is_a?(String) && value.strip.empty?) end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 7236f71..e3b63c0 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -681,7 +681,26 @@ module MCP client_secret = client_info_required_value(client_info, "client_secret") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") - form = form.merge("client_id" => client_id) + form = if token_endpoint_auth_method == "private_key_jwt" + # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of + # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046). + # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is + # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity). + # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated. + unless @provider.respond_to?(:client_assertion) + raise AuthorizationError, + "token_endpoint_auth_method is private_key_jwt but the provider does not " \ + "implement `client_assertion(audience:)`." + end + + form.merge( + "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, + "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), + ) + else + form.merge("client_id" => client_id) + end + headers = {} if client_secret case token_endpoint_auth_method diff --git a/lib/mcp/client/oauth/jwt_client_assertion.rb b/lib/mcp/client/oauth/jwt_client_assertion.rb new file mode 100644 index 0000000..5311a05 --- /dev/null +++ b/lib/mcp/client/oauth/jwt_client_assertion.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" + +module MCP + class Client + module OAuth + # Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt` + # client authentication method used by the `client_credentials` grant + # (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046). + # + # The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency + # (the TypeScript and Python SDKs use jose and PyJWT for the same assertion). + # Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id, + # `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`, + # and a unique `jti`. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046 + # - https://www.rfc-editor.org/rfc/rfc7523#section-2.2 + module JWTClientAssertion + # Raised when `signing_algorithm` is not supported. + class UnsupportedAlgorithmError < ArgumentError; end + + # Raised when the private key cannot be parsed or does not match + # the requested signing algorithm (e.g. an RSA key with ES256). + class InvalidKeyError < ArgumentError; end + + # RFC 7523 Section 2.2 `client_assertion_type` value. + ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + # Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds` + # and the Python SDK's `lifetime_seconds` default. + DEFAULT_LIFETIME = 300 + + # ES256 produces a raw `r || s` JWS signature of two 32-byte integers. + ES256_COMPONENT_BYTES = 32 + private_constant :ES256_COMPONENT_BYTES + + SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze + + class << self + # Returns a signed compact-serialization JWT (`header.payload.signature`). + # + # @param client_id [String] The pre-registered OAuth client identifier. + # @param audience [String] The authorization server's issuer identifier. + # @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or + # traditional encoding) or an already-parsed key. + # @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or + # `"RS256"` (RSA key). + # @param lifetime [Integer] Seconds until the `exp` claim expires. + def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME) + unless SUPPORTED_ALGORITHMS.include?(signing_algorithm) + raise UnsupportedAlgorithmError, + "signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})." + end + + key = parse_key(private_key) + validate_key!(key, signing_algorithm) + + now = Time.now.to_i + header = { alg: signing_algorithm, typ: "JWT" } + claims = { + iss: client_id, + sub: client_id, + aud: audience, + exp: now + lifetime, + iat: now, + jti: SecureRandom.uuid, + } + + signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}" + "#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}" + end + + private + + def parse_key(private_key) + return private_key if private_key.is_a?(OpenSSL::PKey::PKey) + + OpenSSL::PKey.read(private_key.to_s) + rescue OpenSSL::PKey::PKeyError => e + raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}." + end + + def validate_key!(key, signing_algorithm) + case signing_algorithm + when "ES256" + unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1" + raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve." + end + when "RS256" + unless key.is_a?(OpenSSL::PKey::RSA) + raise InvalidKeyError, "RS256 requires an RSA private key." + end + end + + return if key.private? + + raise InvalidKeyError, "private_key must contain the private component to sign assertions." + end + + def sign(key, signing_algorithm, signing_input) + der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) + return der unless signing_algorithm == "ES256" + + ecdsa_der_to_raw(der) + end + + # `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`, + # while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte + # `r || s` concatenation with each integer left-padded to 32 bytes. + def ecdsa_der_to_raw(der) + OpenSSL::ASN1.decode(der).value.map do |integer| + integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0") + end.join.then { |hex| [hex].pack("H*") } + end + + def base64url(data) + Base64.urlsafe_encode64(data, padding: false) + end + end + end + end + end +end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index c62395f..c112aba 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -68,6 +68,98 @@ module MCP provider.clear_tokens! assert_nil(provider.tokens) end + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_es256_key + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + end + + def private_key_jwt_provider(key: generate_es256_key) + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: key, + signing_algorithm: "ES256", + ) + end + + def test_initialize_accepts_private_key_jwt + provider = private_key_jwt_provider + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("private_key_jwt", info["token_endpoint_auth_method"]) + end + + def test_initialize_private_key_jwt_does_not_persist_the_key_or_a_secret + # The PEM must never reach a (potentially persistent) storage backend. + provider = private_key_jwt_provider + + info = provider.client_information + refute(info.key?("client_secret")) + refute(info.values.any? { |value| value.to_s.include?("PRIVATE KEY") }) + end + + def test_initialize_private_key_jwt_requires_private_key + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_requires_signing_algorithm + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + ) + end + end + + def test_initialize_private_key_jwt_rejects_client_secret + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_fails_fast_on_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: OpenSSL::PKey::RSA.new(2048), + signing_algorithm: "ES256", + ) + end + end + + def test_client_assertion_returns_signed_jwt_for_the_audience + provider = private_key_jwt_provider + + assertion = provider.client_assertion(audience: "https://auth.example.com") + payload_segment = assertion.split(".")[1] + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + end end end end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index b92b9f9..b8b96cc 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -175,6 +175,57 @@ module MCP end end + def test_run_client_credentials_with_private_key_jwt_sends_signed_assertion + # SEP-1046 / RFC 7523 Section 2.2: the token request authenticates + # with a signed JWT assertion; `client_id` and any shared secret are + # absent from the body, and the assertion's audience is the AS issuer. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + token_endpoint: "#{@auth_base}/token", + grant_types_supported: ["client_credentials"], + token_endpoint_auth_methods_supported: ["private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + ), + ) + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); + # fall back to the pre-3.0 API on older Rubies. + key = if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: key, + signing_algorithm: "ES256", + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + payload_segment = form["client_assertion"].to_s.split(".")[1].to_s + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + form["grant_type"] == "client_credentials" && + form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + !form.key?("client_id") && + !form.key?("client_secret") && + req.headers["Authorization"].nil? && + claims["iss"] == "cc-client" && + claims["sub"] == "cc-client" && + claims["aud"] == @auth_base && + form["resource"] == "https://srv.example.com/mcp" + end + end + def test_run_uses_authorization_code_grant_for_default_provider # A standard `Provider` declares `authorization_flow == :authorization_code`, # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. diff --git a/test/mcp/client/oauth/jwt_client_assertion_test.rb b/test/mcp/client/oauth/jwt_client_assertion_test.rb new file mode 100644 index 0000000..412f966 --- /dev/null +++ b/test/mcp/client/oauth/jwt_client_assertion_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class JWTClientAssertionTest < Minitest::Test + ES256_COMPONENT_BYTES = 32 + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_ec_key(curve) + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate(curve) + else + OpenSSL::PKey::EC.new(curve).tap(&:generate_key) + end + end + + def es256_key + @es256_key ||= generate_ec_key("prime256v1") + end + + def rs256_key + @rs256_key ||= OpenSSL::PKey::RSA.new(2048) + end + + def generate(private_key: es256_key, signing_algorithm: "ES256", **kwargs) + JWTClientAssertion.generate( + client_id: "cc-client", + audience: "https://auth.example.com", + private_key: private_key, + signing_algorithm: signing_algorithm, + **kwargs, + ) + end + + def decode_segment(segment) + JSON.parse(Base64.urlsafe_decode64(segment + "=" * (-segment.length % 4))) + end + + # JWS ES256 signatures are the raw 64-byte `r || s` concatenation; + # OpenSSL verifies the ASN.1 DER form, so convert back for verification. + def ecdsa_raw_to_der(raw) + r = OpenSSL::BN.new(raw[0, ES256_COMPONENT_BYTES], 2) + s = OpenSSL::BN.new(raw[ES256_COMPONENT_BYTES, ES256_COMPONENT_BYTES], 2) + OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der + end + + def test_generate_builds_compact_jws_with_rfc7523_claims + before = Time.now.to_i + assertion = generate + after = Time.now.to_i + + header_segment, payload_segment, _signature_segment = assertion.split(".") + header = decode_segment(header_segment) + claims = decode_segment(payload_segment) + + assert_equal({ "alg" => "ES256", "typ" => "JWT" }, header) + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + assert_includes(before..after, claims["iat"]) + assert_equal(claims["iat"] + JWTClientAssertion::DEFAULT_LIFETIME, claims["exp"]) + refute_empty(claims["jti"]) + end + + def test_generate_uses_unpadded_base64url_segments + assertion = generate + + segments = assertion.split(".") + assert_equal(3, segments.size) + segments.each do |segment| + refute_match(%r{[+/=]}, segment, "JWS segments must be unpadded base64url") + end + end + + def test_generate_es256_signature_verifies_with_the_public_key + assertion = generate + + header_segment, payload_segment, signature_segment = assertion.split(".") + raw_signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal(ES256_COMPONENT_BYTES * 2, raw_signature.bytesize) + assert( + es256_key.verify( + OpenSSL::Digest.new("SHA256"), + ecdsa_raw_to_der(raw_signature), + "#{header_segment}.#{payload_segment}", + ), + "ES256 signature must verify against the signing input", + ) + end + + def test_generate_rs256_signature_verifies_with_the_public_key + assertion = generate(private_key: rs256_key, signing_algorithm: "RS256") + + header_segment, payload_segment, signature_segment = assertion.split(".") + signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal("RS256", decode_segment(header_segment)["alg"]) + assert(rs256_key.verify(OpenSSL::Digest.new("SHA256"), signature, "#{header_segment}.#{payload_segment}")) + end + + def test_generate_accepts_pem_encoded_private_key + assertion = generate(private_key: es256_key.to_pem) + + refute_empty(assertion) + end + + def test_generate_honors_lifetime_override + assertion = generate(lifetime: 60) + + claims = decode_segment(assertion.split(".")[1]) + assert_equal(claims["iat"] + 60, claims["exp"]) + end + + def test_generate_uses_a_unique_jti_per_assertion + jtis = Array.new(2) { decode_segment(generate.split(".")[1])["jti"] } + + refute_equal(jtis[0], jtis[1]) + end + + def test_generate_rejects_unsupported_algorithm + assert_raises(JWTClientAssertion::UnsupportedAlgorithmError) do + generate(signing_algorithm: "HS256") + end + end + + def test_generate_rejects_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key, signing_algorithm: "ES256") + end + + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: es256_key, signing_algorithm: "RS256") + end + end + + def test_generate_rejects_ec_key_on_the_wrong_curve + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: generate_ec_key("secp384r1"), signing_algorithm: "ES256") + end + end + + def test_generate_rejects_unparseable_pem + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: "not a pem") + end + end + + def test_generate_rejects_public_only_key + # `RSA#public_key` is available on every supported openssl version, + # unlike `public_to_pem` (openssl 3.0+), so the public-only check is + # exercised with an RSA key. + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key.public_key, signing_algorithm: "RS256") + end + end + end + end + end +end From 4401833333944bc5176ca86eefd23480beb4c8c3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 30 Jun 2026 11:34:17 +0900 Subject: [PATCH 128/247] Recognize multi round-trip `input_required` results per SEP-2322 ## Motivation and Context SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 spec release) introduces Multi Round-Trip Requests: instead of issuing in-flight server-to-client JSON-RPC requests, a server answers with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map (of `sampling/createMessage`, `roots/list`, and `elicitation/create` request shapes) and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. The wire contract (the `resultType` discriminator and the `inputRequests`/`requestState` shape) stayed stable across all three closed TypeScript prototype iterations (typescript-sdk#2062/#2065, the v2-stateless stack, and #2251) and the Python draft (python-sdk#2322), but the server-side suspend/resume mechanism is still unsettled in both SDKs (typescript-sdk#2251 was put on hold on 2026-06-08). This change therefore implements only the stable, additive vocabulary and the client-side recognition, leaving server emission and automatic resumption for a follow-up once the reference design lands: - New `MCP::ResultType` module with `COMPLETE` and `INPUT_REQUIRED` constants documenting the `resultType` values. - `MCP::Client` raises the new `MCP::Client::InputRequiredError` (exposing `input_requests`, `request_state`, and the raw `result`) when any response carries `resultType: "input_required"`, instead of silently returning a non-final result as if it were the answer. The check lives in the shared request path, so every client method is covered. Servers on stable protocol versions never emit `resultType`, so default behavior is unchanged. Part of #382. ## How Has This Been Tested? New tests in `test/mcp/client_test.rb`: - `call_tool` raises `InputRequiredError` for an `input_required` result and exposes `input_requests`, `request_state`, and the full raw result - `call_tool` returns normally when `resultType` is `"complete"` and when it is absent (wire-compat regression for stable-protocol servers) - `list_tools` also raises for `input_required` results, proving the recognition covers the shared request path `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None for spec-compliant stable servers, which never send `resultType`. A response that does carry `resultType: "input_required"` now raises `MCP::Client::InputRequiredError` instead of being returned as a final result, which was always a misinterpretation of the draft semantics. --- README.md | 10 ++++++ lib/mcp.rb | 1 + lib/mcp/client.rb | 37 ++++++++++++++++++++ lib/mcp/result_type.rb | 18 ++++++++++ test/mcp/client_test.rb | 77 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 lib/mcp/result_type.rb diff --git a/README.md b/README.md index b986c26..faf540b 100644 --- a/README.md +++ b/README.md @@ -2232,6 +2232,16 @@ The client provides a wrapper class for tools returned by the server: This class provides easy access to tool properties like name, description, input schema, and output schema. +### Multi-Round-Trip Results (Experimental, SEP-2322) + +The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi Round-Trip Requests: instead of issuing `sampling/createMessage`, `roots/list`, +or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map +and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. + +The Ruby client recognizes such results and raises `MCP::Client::InputRequiredError` instead of returning them as if they were final. The error exposes `input_requests`, `request_state`, +and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` +are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. + ## Conformance Testing The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). diff --git a/lib/mcp.rb b/lib/mcp.rb index 867eb02..53684e4 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -16,6 +16,7 @@ module MCP autoload :Prompt, "mcp/prompt" autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" + autoload :ResultType, "mcp/result_type" autoload :Server, "mcp/server" autoload :ServerSession, "mcp/server_session" autoload :Tool, "mcp/tool" diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f3..0aefad9 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -5,6 +5,7 @@ require_relative "client/stdio" require_relative "client/http" require_relative "client/paginated_result" require_relative "client/tool" +require_relative "result_type" module MCP class Client @@ -34,6 +35,25 @@ module MCP # server-returned JSON-RPC error, which is raised as `ServerError`. class ValidationError < StandardError; end + # Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of + # a final result. The result is not an error on the wire: it asks the client to fulfill the server's + # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with + # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue + # the original request with `inputResponses` plus the echoed opaque `requestState`. + # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests` + # and respond manually. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + class InputRequiredError < StandardError + attr_reader :input_requests, :request_state, :result + + def initialize(message, input_requests:, request_state: nil, result: nil) + super(message) + @input_requests = input_requests + @request_state = request_state + @result = result + end + end + # Raised when the server responds 404 to a request containing a session ID, # indicating the session has expired. Inherits from `RequestHandlerError` for # backward compatibility with callers that rescue the generic error. Per spec, @@ -422,9 +442,26 @@ module MCP raise ServerError.new(error["message"], code: error["code"], data: error["data"]) end + raise_on_input_required(response) + response end + # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result. + # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them. + def raise_on_input_required(response) + result = response.is_a?(Hash) ? response["result"] : nil + return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + + raise InputRequiredError.new( + "Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \ + "Inspect `input_requests` to respond manually.", + input_requests: result["inputRequests"] || {}, + request_state: result["requestState"], + result: result, + ) + end + # Generates a fresh JSON-RPC request id for an outgoing request. # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through # an `MCP::Cancellation` token instead. diff --git a/lib/mcp/result_type.rb b/lib/mcp/result_type.rb new file mode 100644 index 0000000..9eeb394 --- /dev/null +++ b/lib/mcp/result_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module MCP + # Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests) + # for the MCP 2026-07-28 draft. + # + # A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map + # of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus + # an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue + # the original request with `inputResponses` and the echoed `requestState`. A missing `resultType` + # or `"complete"` is a final result. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + module ResultType + COMPLETE = "complete" + INPUT_REQUIRED = "input_required" + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47c..b9fa6b0 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -164,6 +164,83 @@ module MCP assert_equal([{ type: "text", text: "Hello, world!" }], content) end + def test_call_tool_raises_input_required_error_when_result_type_is_input_required + # Per SEP-2322, a result with `resultType: "input_required"` is not a final result; + # surface it instead of returning it as a normal result. + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { + "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } }, + }, + "requestState" => "opaque-state", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::InputRequiredError) do + client.call_tool(tool: tool, arguments: {}) + end + + assert_equal( + { "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } } }, + error.input_requests, + ) + assert_equal("opaque-state", error.request_state) + assert_equal(mock_response["result"], error.result) + end + + def test_call_tool_returns_normally_when_result_type_is_complete + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { + "resultType" => "complete", + "content" => [{ "type" => "text", "text" => "done" }], + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.call_tool(tool: tool, arguments: {}) + + assert_equal("done", result.dig("result", "content", 0, "text")) + end + + def test_call_tool_returns_normally_when_result_type_is_absent + # Regression guard: stable-protocol servers never send `resultType`. + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { "content" => [{ "type" => "text", "text" => "done" }] }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.call_tool(tool: tool, arguments: {}) + + assert_equal("done", result.dig("result", "content", 0, "text")) + end + + def test_list_tools_raises_input_required_error_for_input_required_results + # The recognition lives in the shared request path, so every client method is covered, not only tools/call. + transport = mock + mock_response = { + "result" => { "resultType" => "input_required", "inputRequests" => {} }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::InputRequiredError) { client.list_tools } + end + def test_call_tool_raises_when_no_name_or_tool client = Client.new(transport: mock) From 6d42cf7826b8604d892ec9ae0c3effd792021c1f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 1 Jul 2026 01:30:56 +0900 Subject: [PATCH 129/247] Fix an incorrect `result: null` response to an id-bearing notification message ## Motivation and Context A JSON-RPC notification carries no id and must not receive any response. When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id, the server replied with a successful `result: null`, which is neither a valid MCP result nor a legal reply to a notification. Such a message is now treated as a request for a method that has no request handler and rejected with Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification that omits the id continues to receive no response. Fixes #434. ## How Has This Been Tested? Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, a batch that mixes an id-bearing notification with other messages, custom `notifications/*` methods, and the same case over the HTTP transport. ## Breaking Changes None for spec-conformant clients, which never put an id on a notification. The only behavioral change is that a malformed id-bearing notification now receives a Method not found error instead of a spurious `result: null`. --- lib/mcp/methods.rb | 4 + lib/mcp/server.rb | 7 ++ .../streamable_http_transport_test.rb | 32 +++++++ test/mcp/server_cancellation_test.rb | 90 +++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 0af5e41..9f1a935 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -47,6 +47,10 @@ module MCP end class << self + def notification?(method) + method.is_a?(String) && method.start_with?("notifications/") + end + def ensure_capability!(method, capabilities) case method when PROMPTS_GET, PROMPTS_LIST diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 312de4b..ac66102 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -423,6 +423,13 @@ module MCP end def handle_request(request, method, session: nil, related_request_id: nil) + # A well-formed notification carries no JSON-RPC id and receives no response. + # If a client erroneously sends a notification-only method with an id, the message + # is framed as a request; since notification methods have no request handler, + # returning `nil` here makes `JsonRpcHandler` report "Method not found", matching + # the TypeScript and Python SDKs rather than emitting a spurious `result: null`. + return if Methods.notification?(method) && !related_request_id.nil? + # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id) # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry, # and rescue blocks below. diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 550e926..9b26949 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -80,6 +80,38 @@ module MCP assert_equal({}, body["result"]) end + test "id-bearing notification message is rejected with Method not found" do + 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"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "notifications/cancelled", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) + + assert_equal "123", body["id"] + refute body.key?("result") + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, body["error"]["code"] + end + test "handles POST request with invalid JSON" do request = create_rack_request( "POST", diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index 52679c7..9b1908a 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -144,6 +144,96 @@ module MCP assert_nil response end + test "id-bearing notifications/cancelled is rejected with Method not found, not result: null" do + response = @session.handle( + jsonrpc: "2.0", + id: 1001, + method: Methods::NOTIFICATIONS_CANCELLED, + ) + + assert_equal 1001, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "id-bearing notifications/initialized is rejected with Method not found" do + response = @session.handle( + jsonrpc: "2.0", + id: 1002, + method: Methods::NOTIFICATIONS_INITIALIZED, + ) + + assert_equal 1002, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "id-bearing notifications/progress is rejected with Method not found" do + response = @session.handle( + jsonrpc: "2.0", + id: 1003, + method: Methods::NOTIFICATIONS_PROGRESS, + params: { progressToken: "tok", progress: 1 }, + ) + + assert_equal 1003, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "well-formed notifications/initialized still receives no response" do + response = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_INITIALIZED, + ) + + assert_nil response + end + + test "id-bearing notification mixed into a batch yields only its Method not found error" do + responses = @session.handle([ + { jsonrpc: "2.0", method: Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "x" } }, + { jsonrpc: "2.0", id: 2001, method: Methods::NOTIFICATIONS_CANCELLED }, + { jsonrpc: "2.0", id: 2002, method: Methods::PING }, + ]) + + cancelled_error = responses.find { |response| response[:id] == 2001 } + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, cancelled_error.dig(:error, :code) + + ping_response = responses.find { |response| response[:id] == 2002 } + assert_equal({}, ping_response[:result]) + + # The well-formed notification carries no id and contributes no response. + assert_equal 2, responses.size + end + + test "id-bearing custom notifications/* method is rejected with Method not found" do + called = false + @server.define_custom_method(method_name: "notifications/custom") { called = true } + + response = @session.handle( + jsonrpc: "2.0", + id: 3001, + method: "notifications/custom", + ) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + refute called + end + + test "well-formed custom notifications/* method is dispatched without a response" do + called = false + @server.define_custom_method(method_name: "notifications/custom") { called = true } + + response = @session.handle( + jsonrpc: "2.0", + method: "notifications/custom", + ) + + assert_nil response + assert called + end + test "duplicate cancellation for the same in-flight request is idempotent" do @server.define_tool(name: "slow_dup") do |server_context:| 20.times do From 3339ceb8c17fe0e9613689c8f7b4452b9a7f0f2e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 17:51:01 +0900 Subject: [PATCH 130/247] Add Opt-In `ttlMs` / `cacheScope` Cache Hints to List and Read Results per SEP-2549 ## Motivation and Context SEP-2549 (modelcontextprotocol/modelcontextprotocol#2549, merged for the 2026-07-28 spec release) adds a `CacheableResult` shape with `ttlMs` (freshness lifetime in milliseconds, max-age semantics, 0 = do not cache) and `cacheScope` (`"public"` or `"private"`, whether shared intermediaries may cache) to `ListToolsResult`, `ListPromptsResult`, `ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult`, complementing the `listChanged` notifications. The field shape follows the Python SDK's implementation (python-sdk#2824, `ttlMs` + `cacheScope` with `ttlMs: 0` / `cacheScope: "public"` defaults; the TypeScript SDK's earlier `ttl`-seconds prototype #2070 was superseded by this shape). Because this SDK negotiates protocol versions up to 2025-11-25, where the fields do not exist, emission is opt-in: when neither `ttl_ms` nor `cache_scope` is configured, responses are serialized exactly as before. The fields become REQUIRED in 2026-07-28; a follow-up can auto-emit defaults once that version enters `SUPPORTED_STABLE_PROTOCOL_VERSIONS`. - `MCP::Server.new` gains `ttl_ms:` and `cache_scope:` keywords with validating writers modeled on `page_size=` (`ttl_ms` must be nil or a non-negative Integer; `cache_scope` must be nil, `"public"`, or `"private"`). - A private `apply_cache_metadata` wraps the five emission points. When the user sets only one of the two values, the other is filled with the spec defaults (`ttlMs: 0`, the only universally safe freshness value, and `cacheScope: "public"`, the spec and Python default). - A `resources_read_handler` may return a full `{ contents:, ttlMs:, cacheScope: }` hash to override the server-level hints per result; the documented bare-contents return shape keeps working unchanged. - The client's `ListToolsResult` / `ListPromptsResult` / `ListResourcesResult` / `ListResourceTemplatesResult` structs gain `ttl_ms` and `cache_scope` members populated from the response fields. Resolves #387. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb`: - with `ttl_ms: 5000`, all four list results carry `ttlMs: 5000` and the filled `cacheScope: "public"` default - with only `cache_scope: "private"`, `resources/read` carries `ttlMs: 0` - with neither configured, list and read results omit both fields (wire-format regression) - the hints appear alongside `nextCursor` when paginating - a `resources_read_handler` returning `{ contents:, ttlMs: 60_000 }` overrides the server-level value while the missing `cacheScope` is filled - invalid writer values (`-1`, `1.5`, `"internal"`) raise `ArgumentError` New tests in `test/mcp/client_test.rb` assert all four list result structs expose `ttl_ms` / `cache_scope`, and that both are nil when the server omits the fields. ## Breaking Changes None. Emission is opt-in, the new constructor keywords default to nil, and the client structs gain additive members. --- README.md | 34 +++++++++++++ lib/mcp/client.rb | 14 +++++- lib/mcp/client/paginated_result.rb | 12 +++-- lib/mcp/server.rb | 65 +++++++++++++++++++++--- test/mcp/client_test.rb | 34 +++++++++++++ test/mcp/server_test.rb | 80 ++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b986c26..d308d96 100644 --- a/README.md +++ b/README.md @@ -1850,6 +1850,40 @@ tools = client.tools # => Array of every tool on the server. Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need fine-grained iteration (e.g. to stream-process pages without loading everything into memory). +#### List Result Caching (`ttlMs` / `cacheScope`) + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; +`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). + +Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "public"`). +When neither is set, responses are serialized exactly as before. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + ttl_ms: 60_000, # results stay fresh for one minute + cache_scope: "private", # only the requesting client may cache them +) +``` + +A `resources_read_handler` can override the hints per result by returning a full result hash instead of bare contents: + +```ruby +server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } +end +``` + +On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: + +```ruby +page = client.list_tools +page.ttl_ms # => 60000 (nil when the server sent no hint) +page.cache_scope # => "private" +``` + ### Advanced #### Custom Methods diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f3..f568b25 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -134,7 +134,13 @@ module MCP ) end - ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"]) + ListToolsResult.new( + tools: tools, + next_cursor: result["nextCursor"], + meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) end # Returns every tool available on the server. Iterates through all pages automatically @@ -175,6 +181,8 @@ module MCP resources: result["resources"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end @@ -208,6 +216,8 @@ module MCP resource_templates: result["resourceTemplates"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end @@ -241,6 +251,8 @@ module MCP prompts: result["prompts"] || [], next_cursor: result["nextCursor"], meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end diff --git a/lib/mcp/client/paginated_result.rb b/lib/mcp/client/paginated_result.rb index 18bd26b..eb06ee6 100644 --- a/lib/mcp/client/paginated_result.rb +++ b/lib/mcp/client/paginated_result.rb @@ -4,10 +4,12 @@ module MCP class Client # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination, - # and an optional `meta` hash mirroring the MCP `_meta` response field. - ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true) - ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true) - ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true) - ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true) + # an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549 + # cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and + # `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields. + ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a255640..7e2dca9 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -102,8 +102,11 @@ module MCP include Instrumentation include Pagination + # Allowed values for the SEP-2549 `cacheScope` cache hint. + CACHE_SCOPES = ["public", "private"].freeze + attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :page_size, :client_capabilities + attr_reader :page_size, :client_capabilities, :ttl_ms, :cache_scope def initialize( description: nil, @@ -121,6 +124,8 @@ module MCP configuration: nil, capabilities: nil, page_size: nil, + ttl_ms: nil, + cache_scope: nil, transport: nil ) @description = description @@ -138,6 +143,8 @@ module MCP @resource_index = index_resources_by_uri(resources) @server_context = server_context self.page_size = page_size + self.ttl_ms = ttl_ms + self.cache_scope = cache_scope @configuration = MCP.configuration.merge(configuration) @client = nil @@ -232,6 +239,27 @@ module MCP @page_size = page_size end + # SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results + # (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms` + # and `cache_scope` are nil, results are serialized exactly as before. + def ttl_ms=(ttl_ms) + unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0) + raise ArgumentError, "ttl_ms must be nil or a non-negative integer" + end + + @ttl_ms = ttl_ms + end + + # SEP-2549 cache hint: whether shared intermediaries may cache the result ("public") + # or only the requesting client ("private"). + def cache_scope=(cache_scope) + unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope) + raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\"" + end + + @cache_scope = cache_scope + end + def notify_tools_list_changed return unless @transport @@ -472,7 +500,7 @@ module MCP when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) } + build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) {} @@ -611,7 +639,7 @@ module MCP def list_tools(request) page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) - { tools: page[:items], nextCursor: page[:next_cursor] }.compact + apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) @@ -667,7 +695,7 @@ module MCP def list_prompts(request) page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) - { prompts: page[:items], nextCursor: page[:next_cursor] }.compact + apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil) @@ -696,7 +724,7 @@ module MCP def list_resources(request) page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) - { resources: page[:items], nextCursor: page[:next_cursor] }.compact + apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end # Server implementation should set `resources_read_handler` to override no-op default @@ -708,7 +736,32 @@ module MCP def list_resource_templates(request) page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) - { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact + apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact) + end + + # Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`"; + # a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level + # `ttlMs`/`cacheScope` cache hints per result (SEP-2549). + def build_read_resource_result(handler_result) + result = if handler_result.is_a?(Hash) && handler_result.key?(:contents) + handler_result + else + { contents: handler_result } + end + + apply_cache_metadata(result) + end + + # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added + # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in + # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`). + # Values already in the result win, enabling per-result overrides. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549 + def apply_cache_metadata(result) + explicit = result.key?(:ttlMs) || result.key?(:cacheScope) + return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit + + { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result) end def complete(params, session: nil, related_request_id: nil, cancellation: nil) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47c..296c0f5 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -883,6 +883,40 @@ module MCP assert_equal("cursor1", result.next_cursor) end + def test_list_results_expose_ttl_ms_and_cache_scope + # SEP-2549 cache hints are surfaced on every paginated result struct. + [ + ["tools/list", "tools", :list_tools], + ["prompts/list", "prompts", :list_prompts], + ["resources/list", "resources", :list_resources], + ["resources/templates/list", "resourceTemplates", :list_resource_templates], + ].each do |method, items_key, client_method| + transport = mock + mock_response = { + "result" => { items_key => [], "ttlMs" => 5000, "cacheScope" => "private" }, + } + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == method + end.returns(mock_response).once + + result = Client.new(transport: transport).public_send(client_method) + + assert_equal(5000, result.ttl_ms, "#{method} missing ttl_ms") + assert_equal("private", result.cache_scope, "#{method} missing cache_scope") + end + end + + def test_list_results_have_nil_cache_hints_when_server_omits_them + transport = mock + mock_response = { "result" => { "tools" => [] } } + transport.expects(:send_request).returns(mock_response).once + + result = Client.new(transport: transport).list_tools + + assert_nil(result.ttl_ms) + assert_nil(result.cache_scope) + end + def test_list_tools_with_cursor_param transport = mock diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 22eed19..8ba7556 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2936,6 +2936,86 @@ module MCP assert_empty response.dig(:result, :content) end + test "list results carry ttlMs and cacheScope when ttl_ms is configured" do + # SEP-2549 cache hints. The cacheScope default is "public", matching + # the spec default and the Python SDK. + server = Server.new(name: "ttl_test", ttl_ms: 5000) + + ["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index| + result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result] + + assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs" + assert_equal "public", result[:cacheScope], "#{method} missing cacheScope" + end + end + + test "resources/read carries cache hints when cache_scope is configured" do + # The ttlMs default is 0 (do not cache), the only universally safe value. + server = Server.new(name: "ttl_test", cache_scope: "private") + + result = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///x" }, + })[:result] + + assert_equal 0, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "results omit cache hints when ttl_ms and cache_scope are not configured" do + # Wire-format regression: opt-in emission keeps default output unchanged. + list_result = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] + read_result = @server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 2, + params: { uri: "file:///x" }, + })[:result] + + refute list_result.key?(:ttlMs) + refute list_result.key?(:cacheScope) + refute read_result.key?(:ttlMs) + refute read_result.key?(:cacheScope) + end + + test "cache hints appear alongside nextCursor when paginating" do + tool_a = Tool.define(name: "tool_a", description: "Tool A") + tool_b = Tool.define(name: "tool_b", description: "Tool B") + server = Server.new(name: "ttl_test", tools: [tool_a, tool_b], page_size: 1, ttl_ms: 1000, cache_scope: "private") + + result = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] + + assert_not_nil result[:nextCursor] + assert_equal 1000, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "a resources_read_handler can override the server-level cache hints per result" do + server = Server.new(name: "ttl_test", ttl_ms: 5000) + server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }], ttlMs: 60_000 } + end + + result = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///x" }, + })[:result] + + assert_equal 60_000, result[:ttlMs] + assert_equal "public", result[:cacheScope] + assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents] + end + + test "ttl_ms and cache_scope writers reject invalid values" do + assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: -1) } + assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: 1.5) } + assert_raises(ArgumentError) { Server.new(name: "ttl_test", cache_scope: "internal") } + end + test "#handle tools/list returns paginated results when page_size is set" do tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") From ac392cba553cc4278a9af9a52f387502efa58313 Mon Sep 17 00:00:00 2001 From: Yuki Minamiya Date: Wed, 24 Jun 2026 00:19:52 +0900 Subject: [PATCH 131/247] [Doc] Use registered callable names in examples/README.md The `tools/call` cURL example in `examples/README.md` uses `"name": "ExampleTool"`, but a tool registered by class without an explicit `tool_name` gets a snake_case name derived from its class name (via `StringUtils.handle_from_class_name`), so the example fails with `Tool not found: ExampleTool`. The Tools/Prompts lists in the same file had the same mismatch (`ExampleTool`, `ExamplePrompt`, `NotificationTool`). All entries now use the registered callable names. How Tested: Ran `ruby examples/http_server.rb` and exercised the cURL flow. `tools/list` returned `example_tool` and `echo`; `tools/call` with `"name": "ExampleTool"` returned `Tool not found: ExampleTool`; with `"name": "example_tool"` returned `The sum of 5 and 3 is 8`. `prompts/list` returned `example_prompt`. The full unit suite and RuboCop pass locally. Breaking Changes: None (documentation only). --- examples/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index c0fcb33..6328c4e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -55,9 +55,9 @@ $ ruby examples/http_server.rb The server will start on `http://localhost:9292` and provide: - **Tools**: - - `ExampleTool` - adds two numbers + - `example_tool` - adds two numbers - `echo` - echoes back messages -- **Prompts**: `ExamplePrompt` - echoes back arguments as a prompt +- **Prompts**: `example_prompt` - echoes back arguments as a prompt - **Resources**: `test_resource` - returns example content ### 4. HTTP Client Example (`http_client.rb`) @@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Available Tools:** -- `NotificationTool` - Send custom SSE notifications with optional delays +- `notification_tool` - Send custom SSE notifications with optional delays - `echo` - Simple echo tool for basic testing **Usage:** @@ -237,5 +237,5 @@ Call a tool: ```console curl -i http://localhost:9292 \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"ExampleTool","arguments":{"a":5,"b":3}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}' ``` From c145876bb9a6b8f5e76d48b310ab474bceeb4242 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Jul 2026 02:27:54 +0900 Subject: [PATCH 132/247] Add `server/discover` and stateless lifecycle error codes per SEP-2575 ## Motivation and Context SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration and are purely additive: - The `server/discover` method, which servers MUST implement: sessionless capability discovery returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`. The new `Server#discover` handler reuses the same data `init` already builds, but is state-free and idempotent: it stores no client info, never marks a session initialized, and responds regardless of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`). `serverInfo` is returned unfiltered because discovery precedes version negotiation; the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work. On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with a bare POST in both stateful and stateless modes. - The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003` and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet (that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of `URLElicitationRequiredError` is intentionally left untouched. The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25, where those methods are required. Part of #389. ## How Has This Been Tested? - `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities, server info, and instructions; it responds before `initialize` and with empty capabilities; it does not mark the session initialized (a subsequent `initialize` still succeeds); `instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")` now raises `MethodAlreadyDefinedError`. - `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities. - `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`. - New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None at the wire level for existing clients. One narrow API consequence: servers that previously registered their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because the method is built in. --- README.md | 2 + lib/mcp.rb | 1 + lib/mcp/error_codes.rb | 21 ++++++++ lib/mcp/methods.rb | 4 +- lib/mcp/server.rb | 17 +++++++ .../transports/streamable_http_transport.rb | 8 +++- test/mcp/error_codes_test.rb | 13 +++++ test/mcp/methods_test.rb | 2 + .../streamable_http_transport_test.rb | 35 ++++++++++++++ test/mcp/server_test.rb | 48 +++++++++++++++++++ 10 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 lib/mcp/error_codes.rb create mode 100644 test/mcp/error_codes_test.rb diff --git a/README.md b/README.md index b986c26..296bd8d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ It implements the Model Context Protocol specification, handling model context r ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, + and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/lib/mcp.rb b/lib/mcp.rb index 867eb02..97a81f9 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -12,6 +12,7 @@ module MCP autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" + autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" autoload :Resource, "mcp/resource" diff --git a/lib/mcp/error_codes.rb b/lib/mcp/error_codes.rb new file mode 100644 index 0000000..09bb6fd --- /dev/null +++ b/lib/mcp/error_codes.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module MCP + # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`. + # + # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): + # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not + # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY` + # rejects a request that requires a client capability the request did not declare + # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise + # these codes itself yet. + # + # The values come from the spec's MCP-specific error code block, which is allocated sequentially from + # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + module ErrorCodes + MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 + UNSUPPORTED_PROTOCOL_VERSION = -32022 + end +end diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 9f1a935..d256ab1 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -5,6 +5,8 @@ module MCP INITIALIZE = "initialize" PING = "ping" LOGGING_SET_LEVEL = "logging/setLevel" + # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575). + SERVER_DISCOVER = "server/discover" PROMPTS_GET = "prompts/get" PROMPTS_LIST = "prompts/list" @@ -81,7 +83,7 @@ module MCP require_capability!(method, capabilities, :sampling) when ELICITATION_CREATE require_capability!(method, capabilities, :elicitation) - when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, + when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE # No specific capability required. end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a255640..7bbcdf9 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -163,6 +163,7 @@ module MCP Methods::PROMPTS_LIST => method(:list_prompts), Methods::PROMPTS_GET => method(:get_prompt), Methods::INITIALIZE => method(:init), + Methods::SERVER_DISCOVER => method(:discover), Methods::PING => ->(_) { {} }, Methods::NOTIFICATIONS_INITIALIZED => ->(_) {}, Methods::NOTIFICATIONS_PROGRESS => ->(_) {}, @@ -592,6 +593,22 @@ module MCP }.compact end + # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery. + # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark + # the session initialized, and responds regardless of capability declarations or initialization state, + # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered + # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints + # are not included here yet. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + def discover(_request) + { + supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, + capabilities: capabilities, + serverInfo: server_info, + instructions: instructions, + }.compact + end + def configure_logging_level(request, session: nil) if capabilities[:logging].nil? raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4b8f8ad..b70872a 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -359,7 +359,9 @@ module MCP # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # This matches the TypeScript and Python SDKs. - unless initialize_request?(body) + # `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that + # happens before (or instead of) negotiation. + unless initialize_request?(body) || discover_request?(body) return missing_session_id_response if !@stateless && !session_id protocol_version_error = validate_protocol_version_header(request) @@ -552,6 +554,10 @@ module MCP body.is_a?(Hash) && body[:method] == Methods::INITIALIZE end + def discover_request?(body) + body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER + end + def validate_protocol_version_header(request) header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) diff --git a/test/mcp/error_codes_test.rb b/test/mcp/error_codes_test.rb new file mode 100644 index 0000000..f00da6a --- /dev/null +++ b/test/mcp/error_codes_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ErrorCodesTest < ActiveSupport::TestCase + test "exposes the SEP-2575 stateless lifecycle error codes" do + # The exact values are wire vocabulary shared with other SDKs. + assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY) + assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION) + end + end +end diff --git a/test/mcp/methods_test.rb b/test/mcp/methods_test.rb index 1be8cc6..c6ae11a 100644 --- a/test/mcp/methods_test.rb +++ b/test/mcp/methods_test.rb @@ -24,6 +24,8 @@ module MCP # Server methods and notifications ensure_capability_does_not_raise_for Methods::INITIALIZE + # `server/discover` (SEP-2575) must respond regardless of declared capabilities. + ensure_capability_does_not_raise_for Methods::SERVER_DISCOVER ensure_capability_raises_error_for Methods::PROMPTS_LIST, required_capability_name: "prompts" ensure_capability_raises_error_for Methods::PROMPTS_GET, required_capability_name: "prompts" diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9b26949..0382426 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -551,6 +551,41 @@ module MCP assert_equal 200, response[0] end + test "allows server/discover POST without session ID in stateful mode" do + # Per SEP-2575, `server/discover` is sessionless capability discovery and is exempt + # from the session requirement, like `initialize`. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json, + ) + + response = @transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + refute response[1].key?("Mcp-Session-Id") + end + + test "allows server/discover POST without session ID in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json, + ) + + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + end + test "rejects duplicate SSE connection with 409" do # Create a session init_request = create_rack_request( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 22eed19..1f2e621 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -123,6 +123,54 @@ module MCP assert_instrumentation_data({ method: "ping" }) end + test "#handle server/discover returns supported versions, capabilities, server info, and instructions" do + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + result = response[:result] + + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions] + assert_equal @server.capabilities, result[:capabilities] + assert_equal @server_name, result.dig(:serverInfo, :name) + assert_equal "1.2.3", result.dig(:serverInfo, :version) + assert_equal "Optional instructions for the client", result[:instructions] + end + + test "#handle server/discover responds before initialize and regardless of capabilities" do + # Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate. + server = Server.new(name: "discover_test", capabilities: {}) + + response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) + end + + test "#handle server/discover does not mark the session initialized" do + session = ServerSession.new(server: @server, transport: mock) + + @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + + refute_predicate session, :initialized? + # `initialize` must still succeed afterwards. + response = @server.handle( + { jsonrpc: "2.0", method: "initialize", id: 2, params: { clientInfo: { name: "c", version: "1" } } }, + session: session, + ) + refute_nil response[:result] + end + + test "#handle server/discover omits instructions when the server has none" do + server = Server.new(name: "discover_test") + + response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + + refute response[:result].key?(:instructions) + end + + test "#define_custom_method raises for server/discover" do + assert_raises(Server::MethodAlreadyDefinedError) do + @server.define_custom_method(method_name: "server/discover") { {} } + end + end + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", From 36ff2627e0fd11299de5c86b2e3c8b67ff073e8e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Jul 2026 01:12:08 +0900 Subject: [PATCH 133/247] Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352 ## Motivation and Context SEP-2352 (modelcontextprotocol/modelcontextprotocol#2352, merged for the 2026-07-28 spec release) clarifies authorization server binding and migration: clients MUST key persisted client credentials by the issuing authorization server's issuer identifier, MUST NOT reuse credentials across different authorization servers, and MUST re-register when the server's authorization server changes. CIMD `client_id`s (HTTPS URLs) are explicitly portable across authorization servers, and a mismatch SHOULD surface an error rather than silently use mismatched credentials. The Ruby flow previously reused any stored `client_information` with a `client_id` unconditionally, so a server that migrated to a new authorization server would have the old server's credentials (and its dead tokens) replayed forever. This change follows the detect-change, invalidate, and re-register approach that both reference SDKs converged on (typescript-sdk#2348/#2358, merged 2026-06-24; python-sdk#2933/#2936/#2946, which uses the same proactive compare-before-reuse design as this change) while keeping the 4-method storage contract intact by stamping the binding into the persisted hash: - Successful Dynamic Client Registration now persists the credentials with an `"issuer"` member set to the validated AS metadata `issuer` (which `ensure_issuer_matches!` has already pinned to the discovery URL). - `Flow#ensure_client_registered` reuses stored credentials only when the stored issuer matches the current one. Credentials without an issuer binding (data persisted by older SDK versions, or user-supplied pre-registered credentials) are bound to the current issuer on first use. A portable CIMD `client_id` is reused and re-bound. Any other issuer mismatch discards the stale registration and its tokens (tokens minted by the old AS are dead at the new one) and re-registers via CIMD/DCR. - `Flow#refresh!` raises `AuthorizationError` instead of replaying another authorization server's credentials at the token endpoint; `MCP::Client::HTTP#attempt_refresh` already rescues that error and falls back to the full flow, which performs the discard-and-re-register. - The `client_credentials` grant (machine-to-machine) reads static pre-registered credentials without going through `ensure_client_registered`, so it would have silently sent another authorization server's credentials after a migration. `run_client_credentials!` now surfaces an `AuthorizationError` when the stored `issuer` mismatches the current authorization server, implementing the spec's "SHOULD surface an error" (the TypeScript SDK raises `AuthorizationServerMismatchError` here). Re-registration is not an option for pre-registered m2m credentials, so the operator must update the stored credentials; credentials without a recorded issuer keep working unchanged. Two adjacent hardenings from the reference implementations were checked and need no change here: the legacy-fallback issuer stamp is self-consistent because this SDK's fallback registers at the same origin it uses as the issuer (the cross-origin never-reached-AS binding python-sdk#2946 closed cannot arise), and preserving a non-rotated `refresh_token` across refreshes already exists via `preserve_refresh_token`. The TypeScript SDK's redirected-to-issuer callback gate is not adopted: the RFC 9207 `iss` validation (SEP-2468) covers that mix-up leg. Resolves #385. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - stored credentials with a matching issuer skip DCR - an issuer mismatch re-registers and persists the new `client_id` with the new issuer - the stale registration's tokens are cleared before re-registration (observed by aborting the flow at the DCR step) - legacy credentials without an issuer (string- and symbol-keyed) are reused and bound to the current issuer on first use - a CIMD `client_id` with a mismatched issuer is reused without DCR and re-bound - fresh DCR persists the issuer binding - `refresh!` succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one - the `client_credentials` grant succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one All existing flow, HTTP OAuth, and provider tests pass unchanged. ## Breaking Changes None for the documented storage contract: `client_information` hashes gain an additional `"issuer"` member that storages persist opaquely, and credentials stored without one keep working via first-use binding. Behavior changes only in the previously broken case where the authorization server changed, which now re-registers (or, for static machine-to-machine credentials, surfaces an error) instead of replaying the old server's credentials. --- README.md | 4 +- lib/mcp/client/oauth/flow.rb | 86 +++++++++- lib/mcp/client/oauth/in_memory_storage.rb | 4 +- lib/mcp/client/oauth/provider.rb | 5 +- test/mcp/client/oauth/flow_test.rb | 195 ++++++++++++++++++++++ 5 files changed, 288 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b986c26..30a28f9 100644 --- a/README.md +++ b/README.md @@ -2124,7 +2124,9 @@ Optional keyword arguments: - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, - which keeps credentials in process memory only. + which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that + issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically + (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. - `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). When the authorization server advertises `client_id_metadata_document_supported: true`, diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 7236f71..6ee42b4 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -101,6 +101,7 @@ module MCP # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization def run_client_credentials!(as_metadata:, prm:, resource:, scope:) client_info = client_credentials_client_info + ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) form = { "grant_type" => "client_credentials" } effective_scope = resolve_scope(scope: scope, prm: prm) @@ -127,6 +128,25 @@ module MCP info end + # Per SEP-2352, static machine-to-machine credentials are bound to their authorization + # server the same way registered ones are: when the stored `client_information` records + # an `issuer` that differs from the current authorization server, surface an error + # instead of silently sending another server's credentials (the spec's "SHOULD surface + # an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here). + # Re-registration is not an option for the `client_credentials` grant - the credentials + # are pre-registered, not DCR results - so the operator must update the stored credentials. + # Credentials without a recorded issuer keep working unchanged. + def ensure_client_credentials_issuer!(client_info, as_metadata:) + stored_issuer = client_info_required_value(client_info, "issuer") + return if stored_issuer.nil? + return if stored_issuer == as_metadata["issuer"] + + raise AuthorizationError, + "Stored client credentials are bound to a different authorization server " \ + "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \ + "refusing to send them to the current authorization server (SEP-2352)." + end + # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6). # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security # checks before talking to it. @@ -166,6 +186,7 @@ module MCP client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. + ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata) stored_client_info elsif as_metadata["client_id_metadata_document_supported"] == true { "client_id" => @provider.client_id_metadata_document_url } @@ -412,8 +433,8 @@ module MCP end def ensure_client_registered(as_metadata:) - existing = @provider.client_information - return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id") + existing = stored_client_information_for(issuer: as_metadata["issuer"]) + return existing if existing # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`, # if the authorization server advertises Client ID Metadata Document support and the provider has @@ -462,7 +483,12 @@ module MCP "Dynamic client registration response is missing `client_id`." end - @provider.save_client_information(info) + # Per SEP-2352, persisted client credentials are keyed by the issuer identifier of + # the authorization server that minted them, so a later flow can detect an AS change and + # re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field; + # the SDK adds it to the opaque persisted hash. + @provider.save_client_information(info.merge("issuer" => as_metadata["issuer"])) + info end @@ -480,6 +506,60 @@ module MCP metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris)) end + # Returns the stored `client_information` when it may be used against the authorization server + # identified by `issuer`, applying SEP-2352's authorization server binding rules: + # + # - Credentials persisted with an `"issuer"` binding MUST NOT be reused against + # a different authorization server. When the AS changed, the stale registration and its tokens + # are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so + # the flow re-registers. + # - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version, + # or user-supplied pre-registered credentials) are bound to the current issuer on first use. + # - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused + # and re-bound instead of discarded. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352 + def stored_client_information_for(issuer:) + existing = @provider.client_information + return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id") + + stored_issuer = client_info_required_value(existing, "issuer") + return existing if stored_issuer == issuer + return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil? + + client_id = client_info_required_value(existing, "client_id") + return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id) + + @provider.save_client_information(nil) + @provider.clear_tokens! + nil + end + + def rebind_client_information(info, issuer:) + rebound = info.merge("issuer" => issuer) + @provider.save_client_information(rebound) + rebound + end + + # Per SEP-2352, stored client credentials are bound to the authorization server that issued them; + # refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues + # the error and falls back to the full flow, which discards the stale registration and re-registers. + # Credentials without an `"issuer"` binding predate this check and are allowed through; + # CIMD `client_id`s are portable. + def ensure_refreshable_client_information!(client_info, as_metadata:) + stored_issuer = client_info_required_value(client_info, "issuer") + return if stored_issuer.nil? + return if stored_issuer == as_metadata["issuer"] + + client_id = client_info_required_value(client_info, "client_id") + return if Discovery.client_id_metadata_document_url?(client_id) + + raise AuthorizationError, + "Cannot refresh: stored client credentials were issued by a different authorization server " \ + "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \ + "re-registration is required (SEP-2352)." + end + # Reads `key` from a `client_information` hash that may use either string or # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built # `{ client_id:, client_secret: }` and have both work. diff --git a/lib/mcp/client/oauth/in_memory_storage.rb b/lib/mcp/client/oauth/in_memory_storage.rb index 83b261b..23edad9 100644 --- a/lib/mcp/client/oauth/in_memory_storage.rb +++ b/lib/mcp/client/oauth/in_memory_storage.rb @@ -12,7 +12,9 @@ module MCP # - `client_information`: the hash returned by Dynamic Client Registration # or supplied as pre-registered credentials # (`client_id`, optional `client_secret`, optional - # `token_endpoint_auth_method`). + # `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member + # binding the credentials to the authorization server that issued them (SEP-2352); + # custom storages should treat the hash as opaque and persist it as-is. # # This class keeps everything in process memory, so the credentials live # only for the lifetime of the Ruby process. Applications that need diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 4864ae4..2258198 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -29,7 +29,10 @@ module MCP # `WWW-Authenticate` does not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, # `client_information`, and `save_client_information(info)`. Defaults to - # an `InMemoryStorage`. + # an `InMemoryStorage`. Persisted `client_information` is stamped with + # an `"issuer"` member binding it to the authorization server that + # issued it (SEP-2352); when the authorization server changes, the SDK discards + # the stale registration and tokens and re-registers. # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification). # When the authorization server advertises `client_id_metadata_document_supported: true`, diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index b92b9f9..d682cc5 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -155,6 +155,33 @@ module MCP assert_not_requested(:post, "#{@auth_base}/token") end + def test_run_client_credentials_succeeds_with_matching_stored_issuer + provider = client_credentials_provider + provider.storage.save_client_information( + provider.storage.client_information.merge("issuer" => @auth_base), + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_client_credentials_raises_for_mismatched_stored_issuer + # Per SEP-2352, static machine-to-machine credentials bound to another authorization server must surface + # an error instead of being replayed; re-registration is not an option for pre-registered credentials. + provider = client_credentials_provider + provider.storage.save_client_information( + provider.storage.client_information.merge("issuer" => "https://old-as.example.com"), + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_run_client_credentials_requests_scope_from_prm_scopes_supported stub_request(:get, @prm_url).to_return( status: 200, @@ -1035,6 +1062,125 @@ module MCP assert_not_requested(:post, "#{@auth_base}/register") end + # Builds a provider that completes the non-interactive authorization + # flow against the stubs from `setup`, for the SEP-2352 issuer-binding + # tests. + def build_issuer_binding_provider + state_holder = {} + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + end + + def test_run_skips_dcr_when_stored_issuer_matches + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "preregistered-client", "issuer" => @auth_base) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + def test_run_reregisters_when_authorization_server_changes + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "old-as-token") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("test-client", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + + def test_run_clears_stale_tokens_when_authorization_server_changes + # Abort the flow right after the discard (DCR fails) to observe that tokens minted by + # the old authorization server were wiped before any re-registration attempt. + stub_request(:post, "#{@auth_base}/register").to_return(status: 500, body: "") + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "old-as-token", "refresh_token" => "old-as-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_nil(provider.tokens) + assert_nil(provider.client_information) + end + + def test_run_binds_legacy_client_information_to_issuer_on_first_use + # Credentials persisted before issuer binding existed (or supplied as pre-registered credentials) + # are reused and stamped with the current issuer rather than discarded. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal(@auth_base, provider.client_information["issuer"]) + end + + def test_run_binds_symbol_keyed_client_information_to_issuer_on_first_use + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information(client_id: "preregistered-symbol") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal("test-token-from-flow", provider.access_token) + assert_equal(@auth_base, provider.client_information["issuer"]) + end + + def test_run_reuses_portable_cimd_client_id_across_authorization_servers + # Per SEP-2352, a CIMD `client_id` (an HTTPS URL) is portable across authorization servers: + # reuse it and re-bind the issuer instead of discarding the registration. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information( + "client_id" => "https://app.example.com/client-metadata.json", + "issuer" => "https://other-as.example.com", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("https://app.example.com/client-metadata.json", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + + def test_run_persists_issuer_with_dcr_client_information + provider = build_issuer_binding_provider + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("test-client", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + def test_run_skips_dcr_when_as_supports_cimd_and_provider_has_cimd_url # When the AS advertises Client ID Metadata Document support and the provider was configured with a CIMD URL, # the SDK uses the URL as the OAuth `client_id` and does not call /register. @@ -1634,6 +1780,55 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end + def test_refresh_succeeds_when_stored_issuer_matches + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client", "issuer" => @auth_base) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + end + + def test_refresh_raises_when_stored_issuer_differs + # Per SEP-2352, another authorization server's credentials must not be replayed at this token endpoint; + # the caller falls back to the full flow, which discards the stale registration and re-registers. + stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("Token endpoint should not be called.")) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_refresh_uses_cimd_url_as_client_id_when_provider_has_cimd_and_as_supports_it # Regression: the CIMD branch in `ensure_client_registered` does not persist `client_information`, # so a refresh call that requires stored client info would always fail after a CIMD-only flow. From afb968c468c178c4d3294b423fcce250621692f4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 18:40:30 +0900 Subject: [PATCH 134/247] Bound Stateful Session Retention to Prevent an Initialize-Flood DoS ## Motivation and Context In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client. The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count, so the insecure behavior was the default. Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python, and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below): - `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from an explicit `nil`. - A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired), and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects: established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS, which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode. The value is validated as a positive Integer or `nil`. ## How Has This Been Tested? New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out, and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to the new secure default. ## Breaking Changes Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`. --- README.md | 17 ++- .../transports/streamable_http_transport.rb | 61 ++++++++++- .../streamable_http_transport_test.rb | 103 ++++++++++++++++-- 3 files changed, 163 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b986c26..d94d84a 100644 --- a/README.md +++ b/README.md @@ -1758,14 +1758,23 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream. This mode is suitable for simple tool servers that do not need server-initiated requests. -By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds). -When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up: +By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted: +they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent +session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it +(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute, +so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots +and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session). ```ruby -# Session timeout of 30 minutes -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800) +# Tune the limits +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000) + +# Opt out of expiry and/or the cap (not recommended on internet-facing deployments) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil) ``` +Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. + ### Pagination The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4b8f8ad..e79017e 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -24,7 +24,23 @@ module MCP "Connection" => "keep-alive", }.freeze - def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) + # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, + # so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted. + # These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that + # hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport + # rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts + # the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on + # an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to + # opt out of expiry and `max_sessions: nil` to opt out of the cap. + DEFAULT_SESSION_IDLE_TIMEOUT = 1800 + DEFAULT_MAX_SESSIONS = 10_000 + + # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry). + UNSET_IDLE_TIMEOUT = Object.new.freeze + private_constant :UNSET_IDLE_TIMEOUT + + def initialize(server, stateless: false, enable_json_response: false, + session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @@ -32,9 +48,16 @@ module MCP @stateless = stateless @enable_json_response = enable_json_response - @session_idle_timeout = session_idle_timeout @pending_responses = {} + # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, + # which does not apply to stateless mode since it retains no sessions. + @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT) + stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT + else + session_idle_timeout + end + if @session_idle_timeout if @stateless raise ArgumentError, "session_idle_timeout is not supported in stateless mode." @@ -43,6 +66,13 @@ module MCP end end + unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0) + raise ArgumentError, "max_sessions must be a positive Integer or nil" + end + + # The cap guards the stateful session store; stateless mode keeps none. + @max_sessions = stateless ? nil : max_sessions + start_reaper_thread if @session_idle_timeout end @@ -622,13 +652,30 @@ module MCP session_id = SecureRandom.uuid server_session = ServerSession.new(server: @server, transport: self, session_id: session_id) - @mutex.synchronize do + # Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted. + # The check and insert share the mutex so concurrent initializes cannot race past the limit. + reclaimed = [] + inserted = @mutex.synchronize do + # When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected, + # so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired. + if @max_sessions && @sessions.size >= @max_sessions + @sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id| + cleanup_and_collect_stream(id, reclaimed) + end + end + + next false if @max_sessions && @sessions.size >= @max_sessions + @sessions[session_id] = { get_sse_stream: nil, server_session: server_session, last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), } + true end + + reclaimed.each { |stream| close_stream_safely(stream) } + return too_many_sessions_response unless inserted end response = server_session.handle_json(body_string) @@ -655,6 +702,14 @@ module MCP [202, {}, []] end + def too_many_sessions_response + json_rpc_error_response( + status: 503, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached", + ) + end + def handle_regular_request(body_string, session_id, related_request_id: nil) server_session = nil diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9b26949..ece1f77 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -3808,9 +3808,14 @@ module MCP transport.close end - test "default session_idle_timeout is nil and sessions do not expire" do + test "applies a finite session_idle_timeout by default and starts the reaper" do + # Secure default: stateful mode expires idle sessions out of the box. transport = StreamableHTTPTransport.new(@server) - assert_nil(transport.instance_variable_get(:@reaper_thread)) + assert_equal( + StreamableHTTPTransport::DEFAULT_SESSION_IDLE_TIMEOUT, + transport.instance_variable_get(:@session_idle_timeout), + ) + assert(transport.instance_variable_get(:@reaper_thread).alive?) init_request = create_rack_request( "POST", @@ -3818,25 +3823,101 @@ module MCP { "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"] - + # A freshly created session is well within the timeout, so normal use is unaffected. + session_id = transport.handle_request(init_request)[1]["Mcp-Session-Id"] request = create_rack_request( "POST", "/", - { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, - }, + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, ) - response = transport.handle_request(request) - assert_equal(200, response[0]) + assert_equal(200, transport.handle_request(request)[0]) ensure transport.close end + test "session_idle_timeout: nil opts out of expiry" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + assert_nil(transport.instance_variable_get(:@session_idle_timeout)) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "stateless mode keeps no idle timeout despite the secure default" do + transport = StreamableHTTPTransport.new(@server, stateless: true) + assert_nil(transport.instance_variable_get(:@session_idle_timeout)) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "rejects new initialize with 503 once max_sessions is reached" do + transport = StreamableHTTPTransport.new(@server, max_sessions: 2) + + 2.times do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + assert_equal(200, transport.handle_request(request)[0]) + end + + overflow = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + response = transport.handle_request(overflow) + + assert_equal(503, response[0]) + assert_equal(2, transport.instance_variable_get(:@sessions).size) + ensure + transport.close + end + + test "at the cap, reclaims an already-expired session before rejecting" do + # A very short idle timeout so the first session is expired by the time the cap would otherwise reject + # a new initialize; the expired slot is reclaimed. + transport = StreamableHTTPTransport.new(@server, max_sessions: 1, session_idle_timeout: 0.01) + init = lambda do + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + )) + end + + assert_equal(200, init.call[0]) + sleep(0.05) # let the first session's idle timer expire + second = init.call + + # The expired session was reclaimed in-line, so the second initialize succeeds (200) rather than + # being rejected with 503, and the store stays at the cap. + assert_equal(200, second[0]) + assert_equal(1, transport.instance_variable_get(:@sessions).size) + ensure + transport.close + end + + test "max_sessions: nil opts out of the session cap" do + transport = StreamableHTTPTransport.new(@server, max_sessions: nil) + assert_nil(transport.instance_variable_get(:@max_sessions)) + ensure + transport.close + end + + test "raises ArgumentError for an invalid max_sessions" do + [0, -1, 1.5, "10"].each do |invalid| + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_sessions: invalid) } + end + end + test "raises ArgumentError when session_idle_timeout is zero" do error = assert_raises(ArgumentError) do StreamableHTTPTransport.new(@server, session_idle_timeout: 0) From 267b8fa6285453525c81ce43db6b7dcd7a8a8c2f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 2 Jun 2026 11:23:45 +0900 Subject: [PATCH 135/247] Bound stdio frame reads with max_line_bytes ## Motivation and Context CRuby's `IO#gets` without a limit reads until the next newline with no upper bound. A peer that never emits a newline can therefore grow a single String until the process is killed by the OS OOM killer. This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs. It is most relevant when a host application spawns a sandboxed or untrusted MCP server and pipes its stdout into an unconstrained host process, where a memory-capped peer can exhaust the host. Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB, large enough for any realistic frame including base64-embedded images, and the value recommended in the advisory). A frame that reaches the limit without a newline is treated as a transport error: the client raises, and the server reports the error and closes the connection. A short final frame that ends at EOF without a trailing newline is still accepted, so legitimate traffic is unaffected. Both constructors validate `max_line_bytes:` as a positive Integer, since `nil` or a non-positive value makes `IO#gets` read without an upper bound and would silently disable the protection. ## How Has This Been Tested? Server tests cover the `open` read loop stopping and reporting on an over-limit frame, the `send_request` read loop raising on an over-limit response, and a valid final frame without a trailing newline still being processed. Client tests cover `read_response` raising on an over-limit response frame and a normal response still round-tripping within a custom `max_line_bytes`. Both transports also have tests rejecting invalid `max_line_bytes:` values (`nil`, zero, negative, non-Integer) with an `ArgumentError`. The full test suite and RuboCop pass. ## Breaking Changes None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame, so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword on both transport constructors is purely additive and optional. --- README.md | 3 + docs/building-clients.md | 1 + docs/building-servers.md | 2 + lib/mcp/client/stdio.rb | 35 +++++- lib/mcp/server/transports/stdio_transport.rb | 54 ++++++++- test/mcp/client/stdio_test.rb | 110 ++++++++++++++++++ .../server/transports/stdio_transport_test.rb | 85 ++++++++++++++ 7 files changed, 285 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b986c26..b258e5e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + You can run this script and then type in requests to the server at the command line. ```console @@ -1958,6 +1960,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | | `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | | `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | Example usage: diff --git a/docs/building-clients.md b/docs/building-clients.md index f8f37c7..6b4c7d0 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -65,6 +65,7 @@ stdio_transport.close | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | | `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. | | `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | ## HTTP Transport diff --git a/docs/building-servers.md b/docs/building-servers.md index c133c70..30e2880 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -59,6 +59,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + ## Streamable HTTP Transport `MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index a1fe8b4..f7670dd 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -19,13 +19,28 @@ module MCP CLOSE_TIMEOUT = 2 STDERR_READ_SIZE = 4096 + # Default upper bound on a single newline-delimited frame read from the + # server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a + # newline arrives, so a spawned server that never emits one can grow a single + # String until the host process is OOM-killed. 4 MiB is large enough for any + # realistic JSON-RPC frame, including base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + attr_reader :command, :args, :env, :server_info - def initialize(command:, args: [], env: nil, read_timeout: nil) + def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + @command = command @args = args @env = env @read_timeout = read_timeout + @max_line_bytes = max_line_bytes @stdin = nil @stdout = nil @stderr = nil @@ -239,7 +254,7 @@ module MCP loop do ensure_running! wait_for_readable!(method, params) if @read_timeout - line = @stdout.gets + line = read_line(method, params) raise_connection_error!(method, params) if line.nil? parsed = JSON.parse(line.strip) @@ -278,6 +293,22 @@ module MCP ) end + # Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`. + # Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit + # is reached before a newline arrives, which signals a server streaming an unbounded frame. + # A short final frame without a trailing newline (EOF) is still returned, since its length + # stays under the limit. + def read_line(method, params) + line = @stdout.gets("\n", @max_line_bytes) + return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + + raise RequestHandlerError.new( + "Server response frame exceeds #{@max_line_bytes} bytes without a newline", + { method: method, params: params }, + error_type: :internal_error, + ) + end + def raise_connection_error!(method, params) raise RequestHandlerError.new( "Server process closed stdout unexpectedly", diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index c96f526..ec2bd6d 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -9,10 +9,25 @@ module MCP class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 - def initialize(server) + # Default upper bound on a single newline-delimited frame. CRuby's `IO#gets` + # without a limit accumulates bytes until a newline arrives, so a peer that + # never emits one can grow a single String until the process is OOM-killed. + # 4 MiB is large enough for any realistic JSON-RPC frame, including + # base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + + def initialize(server, max_line_bytes: MAX_LINE_BYTES) super(server) @open = false @session = nil + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + + @max_line_bytes = max_line_bytes $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") end @@ -20,7 +35,20 @@ module MCP def open @open = true @session = ServerSession.new(server: @server, transport: self) - while @open && (line = $stdin.gets) + while @open + begin + line = read_line($stdin) + rescue RequestHandlerError => e + # Stop accumulating and end the connection gracefully rather than + # letting an unbounded read exhaust memory or escape as an uncaught + # backtrace. Scoped to the read so genuine request errors raised while + # handling a frame are not swallowed here. + @open = false + MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" }) + break + end + break if line.nil? + response = @session.handle_json(line.strip) send_response(response) if response end @@ -73,7 +101,7 @@ module MCP raise end - while @open && (line = $stdin.gets) + while @open && (line = read_line($stdin)) begin parsed = JSON.parse(line.strip, symbolize_names: true) rescue JSON::ParserError => e @@ -95,6 +123,26 @@ module MCP raise "Transport closed while waiting for response to #{method} request." end + + private + + # Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns + # the line (including its trailing newline) or `nil` at EOF. Raises when the + # limit is reached before a newline arrives, which signals a peer streaming + # an unbounded frame. A short final frame without a trailing newline (EOF) is + # still returned, since its length stays under the limit. + def read_line(io) + line = io.gets("\n", @max_line_bytes) + if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + raise RequestHandlerError.new( + "stdio frame exceeds #{@max_line_bytes} bytes without a newline", + nil, + error_type: :internal_error, + ) + end + + line + end end end end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 9807a1b..e57aef6 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1060,6 +1060,116 @@ module MCP end end + def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + def test_send_request_raises_when_response_frame_exceeds_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification. + stdin_read.gets + + # Read tools/list request, then stream an unbounded frame with no newline. + stdin_read.gets + stdout_write.write("A" * 2000) + stdout_write.flush + end + + transport.connect + + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_match(/exceeds 1024 bytes without a newline/, error.message) + assert_equal(:internal_error, error.error_type) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_accepts_response_within_custom_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + stdin_read.gets + + tools_line = stdin_read.gets + tools_request = JSON.parse(tools_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: tools_request["id"], + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_equal({ "tools" => [] }, response["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_send_notification_writes_json_line_without_waiting_for_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 4671960..c9cda78 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -441,6 +441,91 @@ module MCP end end end + + test "raises ArgumentError when max_line_bytes is not a positive Integer" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + StdioTransport.new(@server, max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + reported = [] + original_stdin = $stdin + original_stdout = $stdout + original_reporter = MCP.configuration.exception_reporter + + begin + $stdin = input + $stdout = output + MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] } + + transport.open + + refute(transport.instance_variable_get(:@open)) + assert_equal(1, reported.size) + error, _ctx = reported.first + assert_instance_of(RequestHandlerError, error) + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + MCP.configuration.exception_reporter = original_reporter + end + end + + test "open processes a valid final frame that ends at EOF without a trailing newline" do + request = { jsonrpc: "2.0", method: "ping", id: "eof" } + input = StringIO.new(JSON.generate(request)) # No trailing newline. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + @transport.open + + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("eof", response[:id]) + assert_equal({}, response[:result]) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "send_request raises when a response frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + transport.instance_variable_set(:@open, true) + + error = assert_raises(RequestHandlerError) do + transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end end end end From 7d9fda4360a38b0e28a2a18d5893c5618f5afb29 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 01:08:20 +0900 Subject: [PATCH 136/247] Add Server-Side MCP Apps Helpers per SEP-1865 ## Motivation and Context SEP-1865 "MCP Apps - Interactive User Interfaces for MCP" is Final on the Extensions Track: (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) an optional extension, negotiated through the SEP-2133 `capabilities.extensions` mechanism, that lets a server ship HTML user interfaces which the host renders for tool results. The normative spec lives in the ext-apps repository (`specification/2026-01-26/apps.mdx`). The Python SDK implements it in-SDK (python-sdk#3003, the `Apps` extension); the TypeScript ecosystem ships it as the separate `@modelcontextprotocol/ext-apps` package on top of the core SDK's SEP-2133 support. Server-side support is a thin convention layer over primitives this SDK already has (SEP-2133 extension capabilities, `_meta` on tools and resources, free-form resource URIs and MIME types). New `MCP::Apps` module: - Wire vocabulary constants shared with the reference implementations: `EXTENSION_ID` (`"io.modelcontextprotocol/ui"` - note `/ui`, not `/apps`), `RESOURCE_MIME_TYPE` (`"text/html;profile=mcp-app"`), `URI_SCHEME` (`"ui://"`), and the legacy flat meta key `RESOURCE_URI_META_KEY` (`"ui/resourceUri"`). - `Apps.capability(mime_types:)` builds the `capabilities.extensions` fragment for `support_extensions` on the server or `connect` on the client. - `Apps.ui_resource(uri:, name:, ...)` builds an `MCP::Resource` for a UI template, enforcing the spec's MUSTs (a `ui://` URI; the parameterized HTML MIME type by default). - `Apps.tool_meta(resource_uri:, visibility:, meta:, legacy:)` builds the `_meta.ui.resourceUri` linkage (optionally also emitting the legacy flat alias, matching the reference server helper that keeps both shapes in sync), merged non-destructively into caller-supplied meta. - `Apps.client_supports?(client_capabilities, mime_type:)` gates the text-only fallback the extension requires for clients that did not declare the capability (symbol/string tolerant). Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge methods, consent for UI-initiated calls) is HOST responsibility and out of scope for a server SDK; a server only ever receives ordinary `resources/read` and `tools/call` requests. The README section states this boundary. Resolves #376. ## How Has This Been Tested? New `test/mcp/apps_test.rb` pins the wire vocabulary and covers the capability fragment, `ui_resource` scheme validation and MIME default, `tool_meta` non-destructive merging, visibility, the legacy alias, and `client_supports?` across symbol/string keys, mimeTypes narrowing, and missing declarations. New integration tests in `test/mcp/server_test.rb` drive a full server: the `initialize` result advertises the extension, tools/list serializes `_meta.ui.resourceUri`, `resources/read` serves the HTML template, and `client_supports?` distinguishes hosts with and without the declared extension. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Also verified end-to-end over stdio: a client connecting with the extension declared negotiates the capability, discovers the tool linkage via `_meta.ui.resourceUri`, and reads the `ui://` template as `text/html;profile=mcp-app` content. ## Breaking Changes None. Purely additive helper module; no existing code paths change. --- README.md | 40 +++++++++++++++ lib/mcp.rb | 1 + lib/mcp/apps.rb | 109 ++++++++++++++++++++++++++++++++++++++++ test/mcp/apps_test.rb | 69 +++++++++++++++++++++++++ test/mcp/server_test.rb | 75 +++++++++++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 lib/mcp/apps.rb create mode 100644 test/mcp/apps_test.rb diff --git a/README.md b/README.md index b986c26..d2c5fe2 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,46 @@ On the client, pass extensions through `connect`: client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) ``` +### MCP Apps (SEP-1865) + +MCP Apps is a Final extension (negotiated via the Capability Extensions mechanism above) that lets a server ship interactive +HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention, +and `MCP::Apps` provides the vocabulary and helpers: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_resources +capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } } + +server = MCP::Server.new( + name: "weather_server", + capabilities: capabilities, + # UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type. + resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], +) + +server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] +end + +# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also +# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec). +server.define_tool( + name: "get_weather", + meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), +) do |server_context:| + # The extension is optional: always return a meaningful text result, and use + # `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content. + MCP::Apps.client_supports?(server.client_capabilities) # => true when the host declared the extension + MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) +end +``` + +Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions) +is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). + ### Server Context and Configuration Block Data #### `server_context` diff --git a/lib/mcp.rb b/lib/mcp.rb index 867eb02..6716e1e 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -8,6 +8,7 @@ require_relative "mcp/version" module MCP autoload :Annotations, "mcp/annotations" + autoload :Apps, "mcp/apps" autoload :Cancellation, "mcp/cancellation" autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" diff --git a/lib/mcp/apps.rb b/lib/mcp/apps.rb new file mode 100644 index 0000000..de09192 --- /dev/null +++ b/lib/mcp/apps.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module MCP + # Server-side helpers for the MCP Apps extension (SEP-1865, Extensions Track, Final): + # interactive user interfaces delivered as `ui://` HTML template resources that hosts + # render for tool results. The extension is negotiated per SEP-2133 through + # `capabilities.extensions` on both sides; a server declares {EXTENSION_ID}, registers + # UI templates as ordinary resources, and links tools to templates via `_meta`. + # + # Everything else defined by the extension (the sandboxed iframe, the `ui/*` + # postMessage bridge methods, consent for UI-initiated calls) is HOST responsibility: + # a server only ever receives ordinary `resources/read` and `tools/call` requests. + # + # Because the extension is optional, a UI-enabled tool MUST still return a meaningful + # text-only result for clients that did not declare the capability; + # use {Apps.client_supports?} to branch. + # + # @example Declaring an Apps-enabled server + # capabilities = MCP::Server::Capabilities.new + # capabilities.support_tools + # capabilities.support_resources + # capabilities.support_extensions(MCP::Apps.capability) + # server = MCP::Server.new( + # name: "weather_server", + # capabilities: capabilities, + # resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], + # ) + # server.define_tool( + # name: "get_weather", + # meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), + # ) { |server_context:| + # ... + # } + # + # https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx + module Apps + # Reverse-DNS extension identifier (note: `/ui`, not `/apps`), shared wire vocabulary with + # the reference `@modelcontextprotocol/ext-apps` package and the Python SDK's `Apps` extension. + EXTENSION_ID = "io.modelcontextprotocol/ui" + # UI template resources MUST use this parameterized MIME type. + RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" + # UI template resource URIs MUST use this scheme. + URI_SCHEME = "ui://" + # Legacy flat `_meta` key linking a tool to its template; the canonical shape is + # the nested `_meta.ui.resourceUri`. {Apps.tool_meta} can emit both. + RESOURCE_URI_META_KEY = "ui/resourceUri" + + extend self + + # The `capabilities.extensions` fragment advertising Apps support. Pass to + # `MCP::Server::Capabilities.new(extensions: ...)` or `support_extensions(...)`, + # or merge into a client's `connect(capabilities: { extensions: ... })`. + def capability(mime_types: [RESOURCE_MIME_TYPE]) + { EXTENSION_ID => { mimeTypes: mime_types } } + end + + # Builds an `MCP::Resource` for a UI template, enforcing the spec's MUSTs: + # a `ui://` URI and the `text/html;profile=mcp-app` MIME type by default. + def ui_resource(uri:, name:, mime_type: RESOURCE_MIME_TYPE, **rest) + unless uri.is_a?(String) && uri.start_with?(URI_SCHEME) + raise ArgumentError, "MCP Apps template URIs must start with #{URI_SCHEME.inspect} (got #{uri.inspect})" + end + + Resource.new(uri: uri, name: name, mime_type: mime_type, **rest) + end + + # Builds the tool `_meta` linking a tool to its UI template, merged non-destructively into + # caller-supplied `meta`. `visibility` restricts who sees the tool (an array of `"model"` / `"app"`). + # `legacy: true` also writes the flat `"ui/resourceUri"` alias, matching the reference server helper + # that keeps both key shapes in sync for older hosts. + def tool_meta(resource_uri:, visibility: nil, meta: nil, legacy: false) + unless resource_uri.is_a?(String) && resource_uri.start_with?(URI_SCHEME) + raise ArgumentError, "resource_uri must start with #{URI_SCHEME.inspect} (got #{resource_uri.inspect})" + end + + ui_entry = { resourceUri: resource_uri } + ui_entry[:visibility] = visibility if visibility + + merged = (meta || {}).merge(ui: ui_entry) + merged[RESOURCE_URI_META_KEY.to_sym] = resource_uri if legacy + merged + end + + # Whether the client declared Apps support for `mime_type` in its `capabilities.extensions` + # (symbol or string keys). UI-enabled tools use this to fall back to a text-only result for + # clients without the extension. + def client_supports?(client_capabilities, mime_type: RESOURCE_MIME_TYPE) + extensions = read_key(client_capabilities, :extensions) + declaration = read_key(extensions, EXTENSION_ID) + return false unless declaration.is_a?(Hash) + + mime_types = read_key(declaration, :mimeTypes) + + # A declaration without mimeTypes advertises the extension without narrowing. + return true if mime_types.nil? + + mime_types.is_a?(Array) && mime_types.include?(mime_type) + end + + private + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + end +end diff --git a/test/mcp/apps_test.rb b/test/mcp/apps_test.rb new file mode 100644 index 0000000..ad00d2a --- /dev/null +++ b/test/mcp/apps_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class AppsTest < ActiveSupport::TestCase + test "exposes the SEP-1865 wire vocabulary" do + # The exact strings are shared with the reference ext-apps package and the Python SDK. + assert_equal "io.modelcontextprotocol/ui", Apps::EXTENSION_ID + assert_equal "text/html;profile=mcp-app", Apps::RESOURCE_MIME_TYPE + assert_equal "ui://", Apps::URI_SCHEME + assert_equal "ui/resourceUri", Apps::RESOURCE_URI_META_KEY + end + + test ".capability builds the extensions fragment" do + assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } }, Apps.capability) + assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } }, Apps.capability(mime_types: ["text/html"])) + end + + test ".ui_resource builds a Resource with the spec defaults and validates the scheme" do + resource = Apps.ui_resource(uri: "ui://weather/dashboard", name: "dashboard", description: "Weather UI") + + assert_instance_of Resource, resource + assert_equal "ui://weather/dashboard", resource.uri + assert_equal "text/html;profile=mcp-app", resource.mime_type + assert_equal "Weather UI", resource.description + assert_raises(ArgumentError) do + Apps.ui_resource(uri: "file:///dashboard.html", name: "dashboard") + end + end + + test ".tool_meta links a tool to its template without clobbering caller meta" do + meta = Apps.tool_meta( + resource_uri: "ui://weather/dashboard", + visibility: ["model", "app"], + meta: { custom: "value" }, + ) + + assert_equal({ custom: "value", ui: { resourceUri: "ui://weather/dashboard", visibility: ["model", "app"] } }, meta) + assert_raises(ArgumentError) do + Apps.tool_meta(resource_uri: "https://example.com") + end + end + + test ".tool_meta emits the legacy flat key when requested" do + meta = Apps.tool_meta(resource_uri: "ui://weather/dashboard", legacy: true) + + assert_equal "ui://weather/dashboard", meta.dig(:ui, :resourceUri) + assert_equal "ui://weather/dashboard", meta[:"ui/resourceUri"] + end + + test ".client_supports? checks the declared extension and mime types" do + declared = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } } } + string_keys = { + "extensions" => { "io.modelcontextprotocol/ui" => { "mimeTypes" => ["text/html;profile=mcp-app"] } }, + } + without_mime_types = { extensions: { "io.modelcontextprotocol/ui" => {} } } + other_mime_type = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } } } + + assert Apps.client_supports?(declared) + assert Apps.client_supports?(string_keys) + assert Apps.client_supports?(without_mime_types) + refute Apps.client_supports?(other_mime_type) + refute Apps.client_supports?({}) + refute Apps.client_supports?(nil) + refute Apps.client_supports?({ extensions: {} }) + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 22eed19..5b37ad9 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3096,5 +3096,80 @@ module MCP assert_equal "b", second_result[:resourceTemplates][0][:name] assert_nil second_result[:nextCursor] end + + test "an MCP Apps server advertises the extension and serves ui:// templates" do + # SEP-1865: the extension rides the SEP-2133 `capabilities.extensions` machinery, + # UI templates are ordinary resources, and tools link to them via `_meta.ui.resourceUri`. + dashboard = Apps.ui_resource(uri: "ui://weather/dashboard", name: "weather_dashboard") + capabilities = Server::Capabilities.new + capabilities.support_tools + capabilities.support_resources + capabilities.support_extensions(Apps.capability) + server = Server.new(name: "apps_test", capabilities: capabilities, resources: [dashboard]) + server.define_tool( + name: "get_weather", + meta: Apps.tool_meta(resource_uri: "ui://weather/dashboard"), + ) do + Tool::Response.new([{ type: "text", text: "Sunny" }]) + end + + server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: Apps::RESOURCE_MIME_TYPE, text: "dashboard" }] + end + + init_response = server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "host", version: "1.0" }, + capabilities: { extensions: Apps.capability }, + }, + }) + + tools_response = server.handle({ + jsonrpc: "2.0", + method: "tools/list", + id: 2, + }) + + read_response = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 3, + params: { uri: "ui://weather/dashboard" }, + }) + + assert_equal( + { mimeTypes: [Apps::RESOURCE_MIME_TYPE] }, + init_response.dig(:result, :capabilities, :extensions, Apps::EXTENSION_ID), + ) + tool_entry = tools_response.dig(:result, :tools, 0) + + assert_equal "ui://weather/dashboard", tool_entry.dig(:_meta, :ui, :resourceUri) + assert_equal "dashboard", read_response.dig(:result, :contents, 0, :text) + # The server can gate the text-only fallback on the client's declaration. + assert Apps.client_supports?(server.client_capabilities) + end + + test "an MCP Apps tool falls back to text for clients without the extension" do + capabilities = Server::Capabilities.new + capabilities.support_extensions(Apps.capability) + server = Server.new(name: "apps_test", capabilities: capabilities) + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "plain_client", version: "1.0" }, + capabilities: {}, + }, + }) + + refute Apps.client_supports?(server.client_capabilities) + end end end From 35466605319a34e4c7808712ae9bb1ca1afb2356 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 21:38:04 +0900 Subject: [PATCH 137/247] Add a Session-Ownership Hook to StreamableHTTPTransport ## Motivation and Context `StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates an incoming request by session existence and idle timeout only, with no binding to a client identity. An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST a `tools/call` on that session and the server executes it, silently poisoning the victim's session. The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path, so this is not covered by it. Binding a session to a user is fundamentally the deploying application's responsibility: the transport hands the server only the JSON-RPC body string and never receives the authenticated principal (`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present. This gives the Ruby SDK the seam to do the same: - A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }` on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses, not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover. `initialize` is exempt because it establishes the session. An application that has an authenticated principal uses the validator to enforce that the caller owns the session; without one, ownership is not enforced. - A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC) is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker. Enforcing ownership against a determined attacker requires the validator. Stateless mode retains no sessions, so neither check applies to it. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed; a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID; the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests; and stateless mode never consults the validator. ## Breaking Changes None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both the session and the request carry an `Origin` and they differ, so existing applications are unaffected. --- README.md | 25 +++ .../transports/streamable_http_transport.rb | 71 +++++-- .../streamable_http_transport_test.rb | 190 ++++++++++++++++++ 3 files changed, 273 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e3b1142..14a2874 100644 --- a/README.md +++ b/README.md @@ -1798,6 +1798,31 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. +#### Session Ownership + +`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session +existence and idle timeout only. It does not bind a session to a user, because the transport never receives +an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, +so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). + +The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` +on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, +so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return +rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded +when the session was created: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, +) +``` + +Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), +it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only +when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. +Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. + ### Pagination The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index fc63988..6f45503 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -58,6 +58,14 @@ module MCP # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already # validates them. + # @param session_request_validator [#call, nil] An optional + # `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE + # against an existing session (regular requests, notifications, and client responses alike). + # Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid` + # session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is + # the deploying application's responsibility (the transport never receives the authenticated identity + # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator, + # ownership is not enforced. def initialize( server, stateless: false, @@ -66,15 +74,17 @@ module MCP max_sessions: DEFAULT_MAX_SESSIONS, allowed_origins: nil, allowed_hosts: nil, - dns_rebinding_protection: true + dns_rebinding_protection: true, + session_request_validator: nil ) super(server) - # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. + # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @sessions = {} @mutex = Mutex.new @stateless = stateless @enable_json_response = enable_json_response + @session_request_validator = session_request_validator @dns_rebinding_protection = dns_rebinding_protection # Host names are case-insensitive, so the allow lists are compared down-cased. @@ -447,16 +457,25 @@ module MCP return session_not_found_response end - handle_initialization(body_string, body) - elsif notification?(body) - dispatch_notification(body_string, session_id) - handle_accepted - elsif response?(body) - return session_not_found_response if !@stateless && !session_exists?(session_id) - - handle_response(body, session_id: session_id) + handle_initialization(request, body_string, body) else - handle_regular_request(body_string, session_id, related_request_id: body[:id]) + # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses, + # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would + # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session). + if !@stateless && session_id && !validate_session_request(request, session_id) + return forbidden_response + end + + if notification?(body) + dispatch_notification(body_string, session_id) + handle_accepted + elsif response?(body) + return session_not_found_response if !@stateless && !session_exists?(session_id) + + handle_response(body, session_id: session_id) + else + handle_regular_request(body_string, session_id, related_request_id: body[:id]) + end end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) @@ -481,6 +500,7 @@ module MCP error_response = validate_and_touch_session(session_id) return error_response if error_response + return forbidden_response unless validate_session_request(request, session_id) protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error @@ -503,6 +523,7 @@ module MCP return missing_session_id_response unless (session_id = extract_session_id(request)) return session_not_found_response unless session_exists?(session_id) + return forbidden_response unless validate_session_request(request, session_id) protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error @@ -564,6 +585,27 @@ module MCP request.env["HTTP_MCP_SESSION_ID"] end + # Session-ownership gate for requests against an existing session (the spec's session-binding guidance). + # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access. + # Two layers, both returning `false` to trigger a 403: + # + # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin` + # at `initialize` and this request carries a different one, reject. Both must be present to compare, + # so non-browser clients that send no `Origin` are unaffected. + # - The application-supplied `session_request_validator`, which can enforce true ownership when it has + # an authenticated principal. + def validate_session_request(request, session_id) + session = @mutex.synchronize { @sessions[session_id] } + return true unless session + + session_origin = session[:origin] + request_origin = request.env["HTTP_ORIGIN"] + return false if session_origin && request_origin && session_origin != request_origin + return @session_request_validator.call(request, session_id) if @session_request_validator + + true + end + def validate_accept_header(request, required_types) accept_header = request.env["HTTP_ACCEPT"] return not_acceptable_response(required_types) unless accept_header @@ -682,7 +724,7 @@ module MCP handle_accepted end - def handle_initialization(body_string, body) + def handle_initialization(request, body_string, body) session_id = nil if @stateless @@ -709,6 +751,9 @@ module MCP get_sse_stream: nil, server_session: server_session, last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), + # Captured for the built-in Origin-consistency defense in `validate_session_request`. + # Not authentication. + origin: request.env["HTTP_ORIGIN"], } true end @@ -962,7 +1007,7 @@ module MCP authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) end - def forbidden_response(message) + def forbidden_response(message = "Forbidden: session request validation failed") json_rpc_error_response( status: 403, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 13bb40f..36d08bc 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4741,6 +4741,196 @@ module MCP transport.close end + test "rejects a POST to a session whose Origin differs from initialize with 403" do + # Both origins are allow-listed so they pass DNS rebinding protection; the rejection + # below comes from the per-session Origin-consistency gate, not from the rebinding check. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com", "https://evil.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + attacker = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(attacker) + + assert_equal(403, response[0]) + ensure + transport.close + end + + test "allows a POST with a matching Origin and one with no Origin" do + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + same_origin = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + # A non-browser client that sends no Origin is not rejected by the built-in check. + no_origin = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "2" }.to_json, + ) + + assert_equal(200, transport.handle_request(same_origin)[0]) + assert_equal(200, transport.handle_request(no_origin)[0]) + ensure + transport.close + end + + test "a custom session_request_validator rejects and allows requests" do + allowed = { value: true } + validator = ->(_request, _session_id) { allowed[:value] } + transport = StreamableHTTPTransport.new(@server, enable_json_response: true, session_request_validator: validator) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + ping = lambda do + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + end + + assert_equal(200, transport.handle_request(ping.call)[0]) + allowed[:value] = false + assert_equal(403, transport.handle_request(ping.call)[0]) + ensure + transport.close + end + + test "the validator also gates notification and response POSTs, not just regular requests" do + # A stolen session ID must not be able to POST `notifications/cancelled` (which would cancel a victim's in-flight request) + # or a client response by bypassing the ownership gate. `initialize` establishes the session and is exempt, + # so a single transport whose validator always rejects still initializes. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + session_request_validator: ->(_request, _session_id) { false }, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + refute_nil(session_id) + + headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id } + cancelled = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "1" } }.to_json, + ) + client_response = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", id: "1", result: {} }.to_json, + ) + + assert_equal(403, transport.handle_request(cancelled)[0]) + assert_equal(403, transport.handle_request(client_response)[0]) + ensure + transport.close + end + + test "session_request_validator receives the request and session id" do + seen = {} + validator = ->(request, session_id) { + seen[:origin] = request.env["HTTP_ORIGIN"] + seen[:session_id] = session_id + true + } + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + session_request_validator: validator, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + )) + + assert_equal(session_id, seen[:session_id]) + assert_equal("https://app.example.com", seen[:origin]) + ensure + transport.close + end + + test "session validation does not apply in stateless mode" do + rejecting = ->(_request, _session_id) { false } + transport = StreamableHTTPTransport.new(@server, stateless: true, enable_json_response: true, session_request_validator: rejecting) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(request) + + # Stateless mode keeps no sessions, so the validator is never consulted. + assert_equal(200, response[0]) + ensure + transport.close + end + private def create_rack_request(method, path, headers, body = nil) From 772e0cb1f9db69312006926eee59a7287ad50166 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 21:38:25 +0900 Subject: [PATCH 138/247] Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS ## Motivation and Context Both transports read a peer-controlled message fully into memory with no upper bound, so a single unauthenticated message can allocate gigabytes and OOM the worker: - `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS in the reporter's measurement. The path runs before session validation, so it is reachable unauthenticated in both stateful and `stateless: true` modes. - `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`, so a peer that never emits a newline grows a single String until the process is OOM-killed. This matters when a host pipes an untrusted server's stdout into an unconstrained process. The spec does not mandate a request size limit, and the reference SDKs vary (the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework). A finite default here bounds the allocation at the transport layer regardless of the deployment, matching the TypeScript SDK's default. - `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post` rejects a declared `Content-Length` over the cap before reading, and also bounds the actual read to one byte past the cap so a missing or spoofed `Content-Length` (chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes `max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap). The constructor validates the new option as a positive Integer, since `nil` or a non-positive value makes the underlying read unbounded and would silently disable the protection. - The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through a separate advisory, so this change carries the remaining piece of the reported stdio fix: an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery, so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request` fails cleanly instead of parsing a truncated frame. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection, and the constructor validation. The client stdio over-limit test now also asserts the transport is closed afterward (`refute_predicate(transport, :connected?)`). `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped). ## Breaking Changes None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional. --- README.md | 12 ++++ lib/mcp/client/stdio.rb | 9 +++ .../transports/streamable_http_transport.rb | 55 ++++++++++++++- test/mcp/client/stdio_test.rb | 3 + .../streamable_http_transport_test.rb | 67 +++++++++++++++++++ 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14a2874..744e8b2 100644 --- a/README.md +++ b/README.md @@ -1823,6 +1823,18 @@ it also records the `Origin` header at `initialize` and rejects a later request when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. +#### Request Size Limits + +`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory +with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, +and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON +string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only +if you exchange unusually large payloads: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) +``` + ### Pagination The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index f7670dd..e835968 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -302,6 +302,15 @@ module MCP line = @stdout.gets("\n", @max_line_bytes) return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + # The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and + # cannot be resumed. Close before raising so a later `send_request` fails cleanly instead + # of parsing a truncated frame. + begin + close + rescue StandardError + nil + end + raise RequestHandlerError.new( "Server response frame exceeds #{@max_line_bytes} bytes without a newline", { method: method, params: params }, diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 6f45503..90d2467 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -39,6 +39,18 @@ module MCP UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT + # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole + # body into memory and parses it, so without a cap a single unauthenticated POST + # can allocate gigabytes and OOM the worker. 4 MiB comfortably + # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 + # payload); raise `max_request_bytes:` for unusually large payloads. Matches the + # TypeScript SDK's 4 MB default. + DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024 + + # Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust + # the stack or amplify parse cost (complements the byte cap). + MAX_JSON_NESTING = 64 + # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -66,6 +78,8 @@ module MCP # the deploying application's responsibility (the transport never receives the authenticated identity # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator, # ownership is not enforced. + # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger + # requests are rejected with HTTP 413. Defaults to 4 MiB. def initialize( server, stateless: false, @@ -75,7 +89,8 @@ module MCP allowed_origins: nil, allowed_hosts: nil, dns_rebinding_protection: true, - session_request_validator: nil + session_request_validator: nil, + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -115,6 +130,12 @@ module MCP # The cap guards the stateful session store; stateless mode keeps none. @max_sessions = stateless ? nil : max_sessions + unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0 + raise ArgumentError, "max_request_bytes must be a positive Integer" + end + + @max_request_bytes = max_request_bytes + start_reaper_thread if @session_idle_timeout end @@ -419,7 +440,9 @@ module MCP content_type_error = validate_content_type(request) return content_type_error if content_type_error - body_string = request.body.read + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + session_id = extract_session_id(request) begin @@ -645,8 +668,34 @@ module MCP ) end + # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust + # memory. A declared `Content-Length` over the cap is rejected + # without reading; the actual read is also bounded to one byte past the cap, so + # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught. + # Returns `nil` when the body exceeds the cap. + def read_bounded_body(request) + content_length = request.content_length + return if content_length && content_length.to_i > @max_request_bytes + + body = request.body.read(@max_request_bytes + 1) + return "" if body.nil? + return if body.bytesize > @max_request_bytes + + body + end + + def payload_too_large_response + json_rpc_error_response( + status: 413, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Payload too large: request body exceeds #{@max_request_bytes} bytes", + ) + end + def parse_request_body(body_string) - JSON.parse(body_string, symbolize_names: true) + # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`, + # a subclass of `JSON::ParserError`, so it is caught below as a parse error. + JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING) rescue JSON::ParserError, TypeError raise InvalidJsonError end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index e57aef6..205fae4 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1111,6 +1111,9 @@ module MCP assert_match(/exceeds 1024 bytes without a newline/, error.message) assert_equal(:internal_error, error.error_type) + # The stream is desynced (leftover bytes in the pipe), so the transport is closed + # rather than left resumable on a corrupt stream. + refute_predicate(transport, :connected?) ensure server_thread.join stdin_read.close diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 36d08bc..7e0b555 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4441,6 +4441,73 @@ module MCP refute headers.frozen?, "SSE response headers should not be frozen" end + test "rejects a request body over max_request_bytes with 413 without reading it all" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # Content-Length present: rejected before reading. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "CONTENT_LENGTH" => oversized.bytesize.to_s }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, JSON.parse(response[2][0]).dig("error", "code") + end + + test "rejects an oversized body even when Content-Length is absent" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # No CONTENT_LENGTH: the bounded read must still catch it. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + end + + test "accepts a request body at the max_request_bytes boundary" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 4096) + body = { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json + assert_operator body.bytesize, :<=, 4096 + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + body, + ) + response = transport.handle_request(request) + + assert_equal 200, response[0] + end + + test "rejects deeply nested JSON beyond the nesting cap as a parse error" do + depth = StreamableHTTPTransport::MAX_JSON_NESTING + 5 + nested = ("[" * depth) + ("]" * depth) + body = %({"jsonrpc":"2.0","method":"initialize","id":"1","params":{"deep":#{nested}}}) + + request = create_rack_request("POST", "/", { "CONTENT_TYPE" => "application/json" }, body) + response = @transport.handle_request(request) + + assert_equal 400, response[0] + assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, JSON.parse(response[2][0]).dig("error", "code") + end + + test "raises ArgumentError for an invalid max_request_bytes" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_request_bytes: invalid) } + end + end + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) From 494f43d3a79a34c3e99f4293a75d5885376406d1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 22:16:05 +0900 Subject: [PATCH 139/247] Release 0.23.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 18 ++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cff0c2..78d8144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.0] - 2026-07-07 + +### Added + +- Add a session-ownership hook to `StreamableHTTPTransport` + +### Changed + +- Require calling `MCP::Client#connect` before sending requests on stdio transport (#427) + ### Deprecated - Annotate Roots, Sampling, and Logging APIs as deprecated per SEP-2577 (#429) +### Fixed + +- Fix an incorrect `result: null` response to an id-bearing notification message (#435) +- Validate `Host` and `Origin` headers to prevent DNS rebinding per MCP 2025-11-25 +- Bound stateful session retention to prevent an initialize-flood DoS +- Bound stdio frame reads with `max_line_bytes` +- Bound request body and frame reads to prevent memory-exhaustion DoS + ## [0.22.0] - 2026-06-27 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index d0fe33f..0e2618f 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.22.0" + VERSION = "0.23.0" end From 04bb739c68d46c023c2eea401fc8375d15e5329e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 13 Jun 2026 00:57:55 +0900 Subject: [PATCH 140/247] Support client-side elicitation with SEP-1034 schema defaults ## Motivation and Context Resolves the `elicitation-sep1034-client-defaults` client conformance scenario (SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034). The Ruby HTTP client could not receive server-to-client requests at all: it never opened the spec's standalone GET SSE listening stream, and SSE events that were not responses were discarded. A server sending `elicitation/create` during a tools/call (such as the conformance harness, which is built on the TypeScript SDK server) either dropped the request silently or deadlocked the tool call waiting for an answer that could never arrive. This change adds three pieces: ### Server-to-client request dispatch `MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler; when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream, the handler's return value is POSTed back to the server as the JSON-RPC `result` (the server ACKs with 202 Accepted, per the transport spec). Methods without a handler are answered with a `-32601` method-not-found error. A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code (for example `-1` for a rejected sampling request, per the sampling spec), mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return; any other error is answered with a `-32603` internal error. In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses. ### A standalone GET SSE listening stream Registering a handler (once connected) opens the spec's listening stream on a background thread, because servers deliver requests that are not tied to a client request on that stream - the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open. Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake without awaiting stream establishment. Reconnection semantics also follow both SDKs: a gracefully closed stream is retried indefinitely after the server's `retry:` interval, while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once they reach the reconnection attempt cap, with a successful stream resetting the count; a 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream. The listening GET uses a 5-minute read timeout matching the Python SDK's `sse_read_timeout` default. `close` terminates the thread. ### SEP-1034 defaults `MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with the `default` values declared in `requestedSchema` properties, never overwriting provided values and skipping properties without a default. This follows the Python SDK, which applies defaults in the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults` (gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up. `MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience. The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with `capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline. ## How Has This Been Tested? New unit tests covering: handler dispatch from a request's POST SSE stream with the response body and 202 ACK verified, the `-32601` answer for unregistered methods, the `-32603` answer for a raising handler, the custom-code answer when a handler raises `ServerRequestError`, listener startup from both registration-after-connect and connect-with-registered-handlers, listener shutdown after consecutive connection failures with the count resetting after a successful stream and an immediate stop on 405, and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean), non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas ## Breaking Changes None. The listening stream and its background thread are created only when a server-request handler is registered; clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior. --- README.md | 24 ++ conformance/client.rb | 19 +- conformance/expected_failures.yml | 2 - lib/mcp/client.rb | 39 +++ lib/mcp/client/elicitation.rb | 51 ++++ lib/mcp/client/http.rb | 148 +++++++++++- test/mcp/client/elicitation_test.rb | 100 ++++++++ test/mcp/client/http_test.rb | 355 ++++++++++++++++++++++++++++ test/mcp/client_test.rb | 19 ++ 9 files changed, 748 insertions(+), 9 deletions(-) create mode 100644 lib/mcp/client/elicitation.rb create mode 100644 test/mcp/client/elicitation_test.rb diff --git a/README.md b/README.md index 744e8b2..dfe0280 100644 --- a/README.md +++ b/README.md @@ -2112,6 +2112,30 @@ response = client.call_tool( The server will send `notifications/progress` back to the client during execution. +#### Server-to-Client Requests (Elicitation) + +Servers can send requests back to the client while one of the client's own requests is in flight - for example, +[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. +Register a handler and advertise the capability on `connect` to respond to them: + +```ruby +client.connect(capabilities: { elicitation: {} }) + +client.on_elicitation do |params| + { + action: "accept", + # Fill fields omitted by the user with the schema's `default` values (SEP-1034) + content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + } +end +``` + +Registering a handler opens a standalone HTTP GET SSE stream on a background thread +([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)), +since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with +a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with +`http_transport.on_server_request("method/name") { |params| ... }`. + #### HTTP Authorization By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: diff --git a/conformance/client.rb b/conformance/client.rb index 7b7ddea..356bd4d 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -125,7 +125,11 @@ end oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) -client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) +capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {} +client.connect( + client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, + capabilities: capabilities, +) case scenario when "initialize" @@ -144,6 +148,19 @@ when "sse-retry" test_reconnection = tools.find { |t| t.name == "test_reconnection" } abort("Tool test_reconnection not found") unless test_reconnection client.call_tool(tool: test_reconnection, arguments: {}) +when "elicitation-sep1034-client-defaults" + # SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring + # a `default` and none required. The handler accepts and fills the omitted fields from those defaults. + client.on_elicitation do |params| + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) } + end + + tools = client.tools + defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" } + + abort("Tool test_client_elicitation_defaults not found") unless defaults_tool + + client.call_tool(tool: defaults_tool, arguments: {}) when %r|\Aauth/| # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) # are observed by the conformance server during `connect` and the subsequent request below. diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index cb397ba..2e67f18 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,7 +1,5 @@ server: [] client: - # TODO: Elicitation not implemented in Ruby client. - - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - auth/client-credentials-jwt - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index fbf04f3..9d0bbf4 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "client/elicitation" require_relative "client/oauth" require_relative "client/stdio" require_relative "client/http" @@ -29,6 +30,20 @@ module MCP end end + # Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`) + # to answer the request with a specific JSON-RPC error code instead of the default internal error. + # Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example, + # the sampling spec answers a rejected request with code `-1`. + # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling + class ServerRequestError < StandardError + attr_reader :code + + def initialize(message, code:) + super(message) + @code = code + end + end + # Raised when a server response fails client-side validation, e.g., a success response # whose `result` field is missing or has the wrong type. This is distinct from a # server-returned JSON-RPC error, which is raised as `ServerError`. @@ -352,6 +367,30 @@ module MCP response.dig("result", "completion") || { "values" => [], "hasMore" => false } end + # Registers a handler for `elicitation/create` requests the server sends while one of + # this client's requests is in flight. The handler receives the request `params` + # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash: + # `{ action: "accept" | "decline" | "cancel", content: { ... } }`. + # + # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); + # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them. + # + # @example Accept with schema defaults applied (SEP-1034) + # client.on_elicitation do |params| + # { + # action: "accept", + # content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + # } + # end + # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation + def on_elicitation(&handler) + unless transport.respond_to?(:on_server_request) + raise ArgumentError, "The transport does not support server-to-client requests" + end + + transport.on_server_request(Methods::ELICITATION_CREATE, &handler) + end + # Sends a `ping` request to the server to verify the connection is alive. # Per the MCP spec, the server responds with an empty result. # diff --git a/lib/mcp/client/elicitation.rb b/lib/mcp/client/elicitation.rb new file mode 100644 index 0000000..52df933 --- /dev/null +++ b/lib/mcp/client/elicitation.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module MCP + class Client + # Client-side helpers for the `elicitation/create` server-to-client request. + # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation + module Elicitation + # Fills fields omitted from `content` with the `default` values declared in + # the elicitation request's `requestedSchema` properties, per SEP-1034. + # Provided values are never overwritten, and properties without a `default` + # are left out so the server can apply its own handling. + # Mirrors the TypeScript SDK's `applyElicitationDefaults`; the Python SDK + # applies defaults in the elicitation callback the same way this helper + # is intended to be used. + # + # @param requested_schema [Hash] The `requestedSchema` from the `elicitation/create` + # request params (string or symbol keys). + # @param content [Hash] Values already collected from the user. + # @return [Hash] `content` (string keys) with defaults filled in. + # + # @example Accept an elicitation request with all defaults + # transport.on_server_request("elicitation/create") do |params| + # { + # action: "accept", + # content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + # } + # end + class << self + def apply_defaults(requested_schema, content = {}) + filled = content.to_h.transform_keys(&:to_s) + properties = requested_schema["properties"] || requested_schema[:properties] + return filled unless properties.is_a?(Hash) + + properties.each do |name, property_schema| + name = name.to_s + next if filled.key?(name) + next unless property_schema.is_a?(Hash) + + if property_schema.key?("default") + filled[name] = property_schema["default"] + elsif property_schema.key?(:default) + filled[name] = property_schema[:default] + end + end + + filled + end + end + end + end +end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 92aac79..eb6ca6e 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -8,9 +8,6 @@ require_relative "../version" module MCP class Client - # TODO: A standalone HTTP GET listening stream for server-initiated messages is not yet implemented; - # GET is currently used only to resume a request's SSE stream after a disconnect. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server class HTTP ACCEPT_HEADER = "application/json, text/event-stream" SSE_ACCEPT_HEADER = "text/event-stream" @@ -27,6 +24,12 @@ module MCP DEFAULT_RECONNECTION_DELAY_MS = 1000 MAX_RECONNECTION_ATTEMPTS = 2 + # How long the standalone GET listening stream may stay idle before the read times out + # and the connection is counted as a failure and retried. Matches the Python SDK's + # `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout + # (60 seconds for Net::HTTP) would recycle quiet streams too eagerly. + SSE_LISTENER_READ_TIMEOUT = 300 + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host # is trivially observed and stolen. @@ -75,8 +78,9 @@ module MCP attr_reader :buffer, :response, :last_event_id, :retry_ms attr_accessor :abortable - def initialize(abortable:) + def initialize(abortable:, on_request: nil) @abortable = abortable + @on_request = on_request @buffer = +"" @parser = nil @response = nil @@ -138,8 +142,14 @@ module MCP next end - if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error")) + next unless parsed.is_a?(Hash) + + if parsed.key?("result") || parsed.key?("error") @response ||= parsed + elsif parsed["method"] && parsed.key?("id") + # A server-to-client request (e.g. `elicitation/create`) delivered + # on the stream while the original request is still pending. + @on_request&.call(parsed) end end end @@ -194,6 +204,24 @@ module MCP @protocol_version = nil @server_info = nil @connected = false + @server_request_handlers = {} + @listener_thread = nil + end + + # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. + # The handler receives the request's `params` (a Hash with string keys, possibly empty) and its return value is + # sent back to the server as the JSON-RPC `result`. + # The handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code. + # Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error. + # Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests + # that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients, + # which start listening after the `initialize` handshake. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server + def on_server_request(method, &handler) + raise ArgumentError, "A handler block is required" unless handler + + @server_request_handlers[method.to_s] = handler + start_listening if connected? end # Performs the MCP `initialize` handshake: sends an `initialize` request @@ -272,6 +300,7 @@ module MCP end @connected = true + start_listening if @server_request_handlers.any? @server_info end @@ -305,8 +334,13 @@ module MCP # The response is consumed incrementally so that an SSE stream the server holds open # (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF # so the response object (and its `Mcp-Session-Id` header) is always available for capture. - stream = SSEStream.new(abortable: method.to_s != MCP::Methods::INITIALIZE) + stream = SSEStream.new( + abortable: method.to_s != MCP::Methods::INITIALIZE, + on_request: ->(message) { dispatch_server_request(message) }, + ) + yield if block_given? + response = begin client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req| req.options.on_data = stream.on_data @@ -636,12 +670,64 @@ module MCP end def clear_session + stop_listening @session_id = nil @protocol_version = nil @server_info = nil @connected = false end + # Opens the standalone GET SSE listening stream on a background thread so server-to-client requests + # can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation). + # Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established. + def start_listening + return if @listener_thread&.alive? + + @listener_thread = Thread.new { listen_for_server_requests } + end + + def stop_listening + @listener_thread&.kill + @listener_thread = nil + end + + # Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and + # then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics), + # while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener + # once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count. + # A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream. + def listen_for_server_requests + stream = SSEStream.new( + abortable: false, + on_request: ->(message) { dispatch_server_request(message) }, + ) + consecutive_failures = 0 + + loop do + begin + client.get("") do |request| + request.headers.update(session_headers) + request.headers["Accept"] = SSE_ACCEPT_HEADER + request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id + request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT + request.options.on_data = stream.on_data + end + + consecutive_failures = 0 + rescue Faraday::Error => e + break if e.response&.dig(:status) == 405 + + consecutive_failures += 1 + + break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS + end + + stream.reset_parser! + + sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) + end + end + def require_faraday! require "faraday" rescue LoadError @@ -694,6 +780,56 @@ module MCP end end + # Answers a server-to-client request received on an SSE stream by invoking the handler registered via + # `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with + # 202 Accepted. Requests without a registered handler are answered with a "method not found" error. + # A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code + # (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error". + # In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler + # failures into JSON-RPC error responses. + def dispatch_server_request(message) + handler = @server_request_handlers[message["method"]] + response = if handler + begin + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + result: handler.call(message["params"] || {}), + } + rescue ServerRequestError => e + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { code: e.code, message: e.message }, + } + rescue StandardError => e + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal error handling #{message["method"]} request: #{e.message}", + }, + } + end + else + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { + code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found: #{message["method"]}", + }, + } + end + + send_client_response(response) + end + + def send_client_response(response) + client.post("", response, session_headers) + end + def parse_json_buffer(buffer, method, params) return if buffer.empty? diff --git a/test/mcp/client/elicitation_test.rb b/test/mcp/client/elicitation_test.rb new file mode 100644 index 0000000..a6d936f --- /dev/null +++ b/test/mcp/client/elicitation_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/elicitation" + +module MCP + class Client + class ElicitationTest < Minitest::Test + REQUESTED_SCHEMA = { + "type" => "object", + "properties" => { + "name" => { "type" => "string", "default" => "John Doe" }, + "age" => { "type" => "integer", "default" => 30 }, + "score" => { "type" => "number", "default" => 95.5 }, + "status" => { "type" => "string", "enum" => ["active", "inactive", "pending"], "default" => "active" }, + "verified" => { "type" => "boolean", "default" => true }, + }, + "required" => [], + }.freeze + + def test_apply_defaults_fills_all_omitted_fields + content = Elicitation.apply_defaults(REQUESTED_SCHEMA) + + assert_equal( + { + "name" => "John Doe", + "age" => 30, + "score" => 95.5, + "status" => "active", + "verified" => true, + }, + content, + ) + end + + def test_apply_defaults_does_not_overwrite_provided_values + content = Elicitation.apply_defaults(REQUESTED_SCHEMA, { "name" => "Alice", "verified" => false }) + + assert_equal( + { + "name" => "Alice", + "verified" => false, + "age" => 30, + "score" => 95.5, + "status" => "active", + }, + content, + ) + end + + def test_apply_defaults_skips_properties_without_a_default + schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string", "default" => "John Doe" }, + "email" => { "type" => "string" }, + }, + } + + content = Elicitation.apply_defaults(schema) + + assert_equal({ "name" => "John Doe" }, content) + refute(content.key?("email")) + end + + def test_apply_defaults_applies_a_false_default + schema = { + "type" => "object", + "properties" => { + "verified" => { "type" => "boolean", "default" => false }, + }, + } + + content = Elicitation.apply_defaults(schema) + + assert_equal({ "verified" => false }, content) + end + + def test_apply_defaults_supports_symbol_keyed_schemas_and_content + schema = { + type: "object", + properties: { + name: { type: "string", default: "John Doe" }, + age: { type: "integer", default: 30 }, + }, + } + + content = Elicitation.apply_defaults(schema, { age: 42 }) + + assert_equal({ "age" => 42, "name" => "John Doe" }, content) + end + + def test_apply_defaults_returns_content_when_schema_has_no_properties + content = Elicitation.apply_defaults({ "type" => "object" }, { "name" => "Alice" }) + + assert_equal({ "name" => "Alice" }, content) + end + end + end +end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 31044b3..a0a1fce 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -853,6 +853,347 @@ module MCP assert_empty(stream.buffer) end + def test_send_request_dispatches_server_request_to_registered_handler + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_client_elicitation_defaults", arguments: {} }, + } + + elicitation_request = { + jsonrpc: "2.0", + id: 0, + method: "elicitation/create", + params: { + message: "Please accept with defaults", + requestedSchema: { + type: "object", + properties: { name: { type: "string", default: "John Doe" } }, + required: [], + }, + }, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{elicitation_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { + jsonrpc: "2.0", + id: 0, + result: { action: "accept", content: { name: "John Doe" } }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return( + status: 202, body: "", + ) + + received_params = nil + client.on_server_request("elicitation/create") do |params| + received_params = params + { + action: "accept", + content: { name: params.dig("requestedSchema", "properties", "name", "default") }, + } + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + assert_equal("Please accept with defaults", received_params["message"]) + end + + def test_send_request_answers_unregistered_server_request_with_method_not_found + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 5, + method: "sampling/createMessage", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 5, + error: { code: -32601, message: "Method not found: sampling/createMessage" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_send_request_answers_raising_handler_with_internal_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 9, + method: "elicitation/create", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 9, + error: { code: -32603, message: "Internal error handling elicitation/create request: boom" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.on_server_request("elicitation/create") { raise "boom" } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_send_request_answers_server_request_error_with_its_code + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "ask_llm", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 11, + method: "sampling/createMessage", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 11, + error: { code: -1, message: "User rejected sampling request" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.on_server_request("sampling/createMessage") do + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_on_server_request_requires_a_block + assert_raises(ArgumentError) do + client.on_server_request("elicitation/create") + end + end + + def test_registering_a_handler_after_connect_listens_on_standalone_get_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + + elicitation_request = { + jsonrpc: "2.0", + id: 7, + method: "elicitation/create", + params: { + message: "Please accept with defaults", + requestedSchema: { + type: "object", + properties: { name: { type: "string", default: "John Doe" } }, + required: [], + }, + }, + } + stub_request(:get, url) + .with( + headers: { "Accept" => "text/event-stream", "Mcp-Session-Id" => "session-abc" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "event: message\ndata: #{elicitation_request.to_json}\n\n", + ) + + expected_response = { + jsonrpc: "2.0", + id: 7, + result: { action: "accept", content: { name: "John Doe" } }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.connect + client.on_server_request("elicitation/create") do |params| + { + action: "accept", + content: { name: params.dig("requestedSchema", "properties", "name", "default") }, + } + end + + wait_until { requested?(response_stub) } + + assert_requested(response_stub) + ensure + client.close + end + + def test_connect_listens_on_standalone_get_stream_when_a_handler_is_registered + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + + get_stub = stub_request(:get, url).with( + headers: { "Accept" => "text/event-stream" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "", + ) + + client.on_server_request("elicitation/create") { { action: "decline" } } + + assert_not_requested(:get, url) + + client.connect + + wait_until { requested?(get_stub) } + + assert_requested(get_stub) + ensure + client.close + end + + def test_listener_stops_after_consecutive_connection_failures + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return(status: 500) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + assert_requested(:get, url, times: HTTP::MAX_RECONNECTION_ATTEMPTS) + ensure + client.close + end + + def test_listener_failure_count_resets_after_a_successful_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return( + { status: 500 }, + { status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "" }, + { status: 500 }, + { status: 500 }, + ) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + # Without the reset, the second failure (the third request) would + # already reach the cap and the fourth request would never be made. + assert_requested(:get, url, times: 4) + ensure + client.close + end + + def test_listener_stops_immediately_when_server_does_not_offer_a_get_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return(status: 405) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + assert_requested(:get, url, times: 1) + ensure + client.close + end + def test_captures_session_id_and_protocol_version_on_initialize stub_request(:post, url) .to_return( @@ -1404,6 +1745,20 @@ module MCP WebMock.stub_request(method, url) end + # Polls until the block is truthy; the listener runs on a background + # thread, so its requests are observed asynchronously. + def wait_until(timeout: 5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until yield + flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + end + + def requested?(stub) + WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive? + end + def url "http://example.com" end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 39fc47c..35f47b1 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -47,6 +47,25 @@ module MCP assert_nil(client.server_info) end + def test_on_elicitation_registers_handler_on_transport + transport = mock + handler = proc { { action: "accept", content: {} } } + transport.expects(:on_server_request).with("elicitation/create") + + Client.new(transport: transport).on_elicitation(&handler) + end + + def test_on_elicitation_raises_when_transport_does_not_support_server_requests + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + + error = assert_raises(ArgumentError) do + Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } + end + + assert_includes(error.message, "does not support server-to-client requests") + end + def test_connected_delegates_to_transport_when_supported transport = mock transport.expects(:connected?).returns(true) From 16a6df340c05eeb506c83127d87d21859028241d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 9 Jul 2026 01:33:56 +0900 Subject: [PATCH 141/247] Add Rails example app ## Motivation and Context The README.md documents the "Rails (mount)" integration pattern, but the repository had no runnable Rails example. This adds a minimal Rails application under examples/rails that mounts StreamableHTTPTransport at /mcp and demonstrates class-based tools and a resource with a read handler. The MCP server is built in config/routes.rb rather than in an initializer because Zeitwerk sets up autoloading after initializers run, so the tool classes in app/tools cannot be referenced there. Code reloading is disabled because the transport holds the server built at boot. The app is booted with puma instead of rackup because rackup inserts `Rack::Lint` in its development default, which rejects the capitalized response header names that the transport currently emits. Closes #44. ## How Has This Been Tested? The curl walkthrough in examples/rails/README.md has been verified end to end against the running app: initialize and the initialized notification, tools/list, tools/call for both tools, resources/list, resources/read, the standalone SSE stream, and session termination via DELETE. ## Breaking Changes None. This only adds an example application and documentation links. --- README.md | 2 + examples/README.md | 15 +++ examples/rails/.gitignore | 3 + examples/rails/Gemfile | 7 ++ examples/rails/README.md | 116 ++++++++++++++++++++++ examples/rails/app/tools/add_tool.rb | 22 ++++ examples/rails/app/tools/greeting_tool.rb | 27 +++++ examples/rails/config.ru | 5 + examples/rails/config/application.rb | 27 +++++ examples/rails/config/environment.rb | 5 + examples/rails/config/routes.rb | 46 +++++++++ 11 files changed, 275 insertions(+) create mode 100644 examples/rails/.gitignore create mode 100644 examples/rails/Gemfile create mode 100644 examples/rails/README.md create mode 100644 examples/rails/app/tools/add_tool.rb create mode 100644 examples/rails/app/tools/greeting_tool.rb create mode 100644 examples/rails/config.ru create mode 100644 examples/rails/config/application.rb create mode 100644 examples/rails/config/environment.rb create mode 100644 examples/rails/config/routes.rb diff --git a/README.md b/README.md index b986c26..1bfc607 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ end the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), so no additional route configuration is needed. +A complete runnable application using this approach is available in [`examples/rails`](examples/rails). + ##### Rails (controller) While the mount approach creates a single server at boot time, the controller approach creates a new server per request. diff --git a/examples/README.md b/examples/README.md index 6328c4e..86931c5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -140,6 +140,21 @@ The client will: - Provide an interactive menu to trigger notifications - Display all received SSE events in real-time +### 7. Rails Server (`rails/`) + +A minimal Rails application that mounts `StreamableHTTPTransport` in its routes, following the "Rails (mount)" pattern from the top-level README. +It demonstrates class-based tools in `app/tools/` and a resource with a read handler. + +**Usage:** + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. + ### Testing with MCP Inspector [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers. diff --git a/examples/rails/.gitignore b/examples/rails/.gitignore new file mode 100644 index 0000000..4910c63 --- /dev/null +++ b/examples/rails/.gitignore @@ -0,0 +1,3 @@ +/Gemfile.lock +/log/ +/tmp/ diff --git a/examples/rails/Gemfile b/examples/rails/Gemfile new file mode 100644 index 0000000..a1494d8 --- /dev/null +++ b/examples/rails/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "mcp", path: "../.." +gem "puma" +gem "rails", "~> 8.0" diff --git a/examples/rails/README.md b/examples/rails/README.md new file mode 100644 index 0000000..6a599c3 --- /dev/null +++ b/examples/rails/README.md @@ -0,0 +1,116 @@ +# Rails Example MCP Server + +A minimal Rails application that serves an MCP server over the Streamable HTTP transport, +following the "Rails (mount)" pattern from the [top-level README](../../README.md). + +The application provides: + +- `add_tool` tool (`app/tools/add_tool.rb`) - adds two numbers, demonstrates `input_schema` +- `greeting_tool` tool (`app/tools/greeting_tool.rb`) - greets a name, demonstrates `annotations` and `server_context` +- `example://rails/readme` resource - a text resource served via `resources_read_handler` + +The MCP server and transport are built once at boot in `config/routes.rb`, where the transport is mounted at `/mcp`. + +## Requirements + +- Ruby >= 3.2 (required by Rails 8; the `mcp` gem itself supports older Rubies) +- curl >= 7.82 (for the `--json` flag used below) + +## Running + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is now available at `http://localhost:9292/mcp`. + +## Testing with cURL + +POST requests must include an `Accept` header that allows both `application/json` and `text/event-stream`, +per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` lines. + +1. Initialize a session and capture the session ID: + +```console +SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ + | grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') +``` + +2. Complete the handshake (expect `202 Accepted`): + +```console +curl -i http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","method":"notifications/initialized"}' +``` + +3. List and call tools: + +```console +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_tool","arguments":{"a":5,"b":3}}}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"greeting_tool","arguments":{"name":"Rails"}}}' +``` + +4. List and read the resource: + +```console +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":5,"method":"resources/list"}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"example://rails/readme"}}' +``` + +5. Optionally, open the standalone SSE stream for server-to-client messages + (in another terminal): + +```console +curl -N http://localhost:9292/mcp \ + -H "Accept: text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" +``` + +6. End the session: + +```console +curl -i -X DELETE http://localhost:9292/mcp -H "Mcp-Session-Id: $SESSION_ID" +``` + +## Testing with MCP Inspector + +Start [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) with `npx @modelcontextprotocol/inspector`, +set Transport Type to "Streamable HTTP", and connect to `http://localhost:9292/mcp`. + +## Notes + +- `StreamableHTTPTransport` keeps session and SSE state in memory, so it must run in a single process. + Puma runs in single mode (`workers 0`) by default; do not enable clustered mode for this app. + When running multiple instances behind a load balancer, use sticky sessions keyed on the `Mcp-Session-Id` header, + or pass `stateless: true` to the transport. +- The server is built once at boot in `config/routes.rb`, so code reloading is disabled + (`config.enable_reloading = false` in `config/application.rb`). + Restart the server after changing tool or resource code. Note that tool classes cannot be referenced from + `config/initializers` because Zeitwerk has not set up autoloading at that point; routes load late enough that they can. +- For a per-request server with request-specific tools or context, see the "Rails (controller)" section in the top-level README, + which uses `stateless: true` and `transport.handle_request(request)` inside a controller action. diff --git a/examples/rails/app/tools/add_tool.rb b/examples/rails/app/tools/add_tool.rb new file mode 100644 index 0000000..b3bf506 --- /dev/null +++ b/examples/rails/app/tools/add_tool.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddTool < MCP::Tool + title "Add Tool" + description "A simple example tool that adds two numbers" + input_schema( + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + ) + + class << self + def call(a:, b:) + MCP::Tool::Response.new([{ + type: "text", + text: "The sum of #{a} and #{b} is #{a + b}", + }]) + end + end +end diff --git a/examples/rails/app/tools/greeting_tool.rb b/examples/rails/app/tools/greeting_tool.rb new file mode 100644 index 0000000..00c8c3f --- /dev/null +++ b/examples/rails/app/tools/greeting_tool.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class GreetingTool < MCP::Tool + title "Greeting Tool" + description "Greets the given name and reports which app served the request" + input_schema( + properties: { + name: { type: "string" }, + }, + required: ["name"], + ) + annotations( + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + read_only_hint: true, + ) + + class << self + def call(name:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello, #{name}! (served by #{server_context[:app_name]})", + }]) + end + end +end diff --git a/examples/rails/config.ru b/examples/rails/config.ru new file mode 100644 index 0000000..14f9aab --- /dev/null +++ b/examples/rails/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +run(Rails.application) diff --git a/examples/rails/config/application.rb b/examples/rails/config/application.rb new file mode 100644 index 0000000..a61ce76 --- /dev/null +++ b/examples/rails/config/application.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" + +require "rails" +require "action_controller/railtie" + +require "mcp" + +module McpRailsExample + class Application < Rails::Application + config.load_defaults(8.0) + + # This example has no views, assets, or database. + config.api_only = true + + # The MCP server is built once at boot (config/routes.rb) and holds references to the tool classes in app/tools, + # so code reloading is disabled; restart the server after changing tool or resource code. + # A reloading app would need `config.to_prepare` and a way to swap the transport's server instead. + config.enable_reloading = false + config.eager_load = true + + config.logger = ActiveSupport::Logger.new($stdout) + end +end diff --git a/examples/rails/config/environment.rb b/examples/rails/config/environment.rb new file mode 100644 index 0000000..e8173e0 --- /dev/null +++ b/examples/rails/config/environment.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "application" + +Rails.application.initialize! diff --git a/examples/rails/config/routes.rb b/examples/rails/config/routes.rb new file mode 100644 index 0000000..76f3c27 --- /dev/null +++ b/examples/rails/config/routes.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# The MCP server and its Streamable HTTP transport are built here, once at boot, +# following the "Rails (mount)" pattern from the top-level README. +# Routes are loaded after the application initializes, so the tool classes in app/tools +# are available at this point (they are not yet available in config/initializers, +# where Zeitwerk has not set up autoloading). +# +# The transport keeps session and SSE state in memory, so run a single server process +# (e.g., Puma with `workers 0`, which is the rackup default). +# See the top-level README for multi-instance and stateless deployment notes. +server = MCP::Server.new( + name: "rails_example_server", + title: "Rails Example Server", + version: "1.0.0", + tools: [AddTool, GreetingTool], + resources: [ + MCP::Resource.new( + uri: "example://rails/readme", + name: "readme", + title: "Example README", + description: "Describes this Rails example MCP server", + mime_type: "text/plain", + ), + ], + server_context: { app_name: "mcp_rails_example" }, +) + +server.resources_read_handler do |params| + [ + { + uri: params[:uri], + mimeType: "text/plain", + text: "This resource is served by the Rails example MCP server (Rails #{Rails.version}).", + }, + ] +end + +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + # `StreamableHTTPTransport` is a Rack app, so it can be mounted directly. + # `mount` directs all HTTP methods on /mcp to the transport, which internally dispatches POST (JSON-RPC messages), + # GET (SSE stream), and DELETE (session termination) per the MCP Streamable HTTP transport spec. + mount(transport => "/mcp") +end From b6df40b134340f714eb789780f46a7c40238bb16 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 6 Jul 2026 21:39:12 +0900 Subject: [PATCH 142/247] Support class-based Resource and ResourceTemplate definitions ## Motivation and Context Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block with manual URI routing. `MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool` and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`, `size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods and `MCP::Server#define_resource` / `#define_resource_template`. The default `resources/read` handler now routes requests automatically: an exact URI match calls the class-based resource's `contents` method, and class-based resource templates match simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments. `contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164. ## How Has This Been Tested? - Added regression tests covering the class-level DSL, URI template matching, auto-routing, `server_context:` opt-in, error responses, and backward compatibility of the no-op default. - Verified end-to-end with a smoke script exercising exact-match reads, template-match reads, unknown URIs, and legacy instance-only setups. ## Breaking Changes No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds the internal URI index. Closes #98 --- README.md | 130 +++++++++++++++++- docs/building-servers.md | 22 +++- lib/mcp/resource.rb | 135 +++++++++++++++++++ lib/mcp/resource_template.rb | 149 +++++++++++++++++++++ lib/mcp/server.rb | 89 ++++++++++++- test/mcp/resource_template_test.rb | 135 +++++++++++++++++++ test/mcp/resource_test.rb | 106 +++++++++++++++ test/mcp/server_test.rb | 204 +++++++++++++++++++++++++++++ 8 files changed, 952 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0fe2983..01a0a82 100644 --- a/README.md +++ b/README.md @@ -974,7 +974,71 @@ MCP spec includes [Resources](https://modelcontextprotocol.io/specification/late ### Reading Resources -The `MCP::Resource` class provides a way to register resources with the server. +Like tools and prompts, resources can be defined in three ways. + +1. As a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: + +```ruby +class MyResource < MCP::Resource + uri "https://example.com/my_resource" + resource_name "my-resource" + title "My Resource" + description "Lorem ipsum dolor sit amet" + mime_type "text/html" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: "Hello from example resource!" + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resources: [MyResource], +) +``` + +`resources/read` requests are routed automatically: when the requested URI matches a registered +class-based resource, its `contents` method is called. `contents` may return an array of +`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. +Like tools, `contents` can opt in to a `server_context:` keyword argument to receive per-request context. + +When class-based resources or resource templates are registered and a `resources/read` request +does not match any of them, the server responds with the standard JSON-RPC Invalid Params error +(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164. + +2. With the `MCP::Resource.define` method, whose block implements `contents`: + +```ruby +resource = MCP::Resource.define( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")] +end +``` + +3. Using the `MCP::Server#define_resource` method: + +```ruby +server = MCP::Server.new(name: "my_server") +server.define_resource( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")] +end +``` + +Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, +in which case the server only lists them: ```ruby resource = MCP::Resource.new( @@ -991,7 +1055,8 @@ server = MCP::Server.new( ) ``` -The server must register a handler for the `resources/read` method to retrieve a resource dynamically. +With plain data resources, the server must register a handler for the `resources/read` method to +retrieve a resource dynamically. ```ruby server.resources_read_handler do |params| @@ -1003,7 +1068,8 @@ server.resources_read_handler do |params| end ``` -otherwise `resources/read` requests will be a no-op. +otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces +the default `resources/read` handling, including the automatic routing to class-based resources described above. For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) @@ -1020,7 +1086,63 @@ end ### Resource Templates -The `MCP::ResourceTemplate` class provides a way to register resource templates with the server. +Resource templates follow the same pattern. Class-based templates declare a `uri_template` and +receive the variables extracted from the requested URI as keyword arguments to `contents`: + +```ruby +class UserProfileTemplate < MCP::ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user-profile" + title "User Profile" + description "Profile data for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: mime_type, + text: { id: user_id }.to_json + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resource_templates: [UserProfileTemplate], +) +``` + +A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`. +An exact match against a registered resource takes precedence over template matching. +`contents` can also opt in to a `server_context:` keyword argument. + +URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: + +- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. +- A variable matches one or more characters excluding `/`. +- Extracted values are not percent-decoded. + +The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, +mirroring the resource variants: + +```ruby +server.define_resource_template( + uri_template: "users://{user_id}/profile", + name: "user-profile", + mime_type: "application/json", +) do |user_id:| + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: "application/json", + text: { id: user_id }.to_json + )] +end +``` + +Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`, +in which case reads must be served by a `resources_read_handler`: ```ruby resource_template = MCP::ResourceTemplate.new( diff --git a/docs/building-servers.md b/docs/building-servers.md index 30e2880..408f4be 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -291,20 +291,28 @@ class MyResource < MCP::Resource resource_name "config" description "Application configuration" mime_type "application/json" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: File.read("config.json") + )] + end + end end server = MCP::Server.new( name: "my_server", - resources: [MyResource], - resources_read_handler: ->(uri, _server_context) { - case uri - when "file:///data/config.json" - { uri: uri, text: File.read("config.json"), mimeType: "application/json" } - end - } + resources: [MyResource] ) ``` +The server automatically routes `resources/read` requests to the matching class-based resource's `contents` method. +Requests for unregistered URIs respond with the JSON-RPC Invalid Params error (`-32602`). To handle reads manually instead, +register a block with `server.resources_read_handler`, which fully replaces the automatic routing. + ## Configuration ```ruby diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 8b5f469..dd87ee2 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -5,6 +5,141 @@ require_relative "resource/embedded" module MCP class Resource + class << self + NOT_SET = Object.new + + attr_reader :uri_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :size_value + attr_reader :meta_value + + def contents(server_context: nil) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uri: uri_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + size: size_value, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_value, nil) + subclass.instance_variable_set(:@name_value, nil) + subclass.instance_variable_set(:@title_value, nil) + subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) + subclass.instance_variable_set(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@size_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri(value = NOT_SET) + if value == NOT_SET + @uri_value + else + @uri_value = value + end + end + + def resource_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def size(value = NOT_SET) + if value == NOT_SET + @size_value + else + @size_value = value + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + Class.new(self) do + uri uri + resource_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + size size + meta meta + define_singleton_method(:contents, &block) if block + end + end + end + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil) diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index e078174..4edbc5d 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,6 +2,155 @@ module MCP class ResourceTemplate + class << self + NOT_SET = Object.new + + # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`. + # Variable names are restricted to valid Regexp named-group names, + # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match. + VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/ + + attr_reader :uri_template_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :meta_value + + def contents(server_context: nil, **params) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uriTemplate: uri_template_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_template_value, nil) + subclass.instance_variable_set(:@uri_template_pattern, nil) + subclass.instance_variable_set(:@name_value, nil) + subclass.instance_variable_set(:@title_value, nil) + subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) + subclass.instance_variable_set(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri_template(value = NOT_SET) + if value == NOT_SET + @uri_template_value + else + @uri_template_pattern = nil + @uri_template_value = value + end + end + + def resource_template_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + # Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions. + # Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match. + # Variables match one or more characters excluding `/`, and values are not percent-decoded. + def match_uri(uri) + match = uri_template_pattern&.match(uri) + match&.named_captures&.transform_keys(&:to_sym) + end + + def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + Class.new(self) do + uri_template uri_template + resource_template_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + meta meta + define_singleton_method(:contents, &block) if block + end + end + + private + + def uri_template_pattern + return if uri_template.nil? + + @uri_template_pattern ||= begin + pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" } + Regexp.new("\\A#{pattern}\\z") + end + end + end + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index bebf9a4..78d0774 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -105,8 +105,8 @@ module MCP # Allowed values for the SEP-2549 `cacheScope` cache hint. CACHE_SCOPES = ["public", "private"].freeze - attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :page_size, :client_capabilities, :ttl_ms, :cache_scope + attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification + attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope def initialize( description: nil, @@ -161,7 +161,7 @@ module MCP @handlers = { Methods::RESOURCES_LIST => method(:list_resources), - Methods::RESOURCES_READ => method(:read_resource_no_content), + Methods::RESOURCES_READ => method(:read_resource), Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, @@ -224,6 +224,34 @@ module MCP validate! end + def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + resource = Resource.define( + uri: uri, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, size: size, meta: meta, + &block + ) + @resources << resource + @resource_index[resource.uri] = resource + + validate! + end + + def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + resource_template = ResourceTemplate.define( + uri_template: uri_template, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, meta: meta, + &block + ) + @resource_templates << resource_template + + validate! + end + + def resources=(resources) + @resources = resources + @resource_index = index_resources_by_uri(resources) + end + def define_custom_method(method_name:, &block) if @handlers.key?(method_name) raise MethodAlreadyDefinedError, method_name @@ -744,10 +772,57 @@ module MCP apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end - # Server implementation should set `resources_read_handler` to override no-op default - def read_resource_no_content(request) - add_instrumentation_data(resource_uri: request[:uri]) - [] + # Default `resources/read` handler: routes to class-based resources and resource templates. + # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered, + # unknown URIs keep the historical no-op `[]` response instead of raising. + def read_resource(request, server_context: nil) + uri = request[:uri] + add_instrumentation_data(resource_uri: uri) + + resource = @resource_index[uri] + if resource.is_a?(Class) + return normalize_resource_contents(call_resource_contents(resource, server_context)) + end + + @resource_templates.each do |template| + next unless template.is_a?(Class) + + params = template.match_uri(uri) + next unless params + + add_instrumentation_data(resource_template: template.uri_template) + return normalize_resource_contents(call_template_contents(template, params, server_context)) + end + + return [] unless class_based_resources_registered? + + raise ResourceNotFoundError.new(uri, request) + end + + def call_resource_contents(resource, server_context) + if accepts_server_context?(resource.method(:contents)) + resource.contents(server_context: server_context) + else + resource.contents + end + end + + def call_template_contents(template, params, server_context) + if accepts_server_context?(template.method(:contents)) + template.contents(**params, server_context: server_context) + else + template.contents(**params) + end + end + + def normalize_resource_contents(result) + contents = result.is_a?(Array) ? result : [result] + contents.map(&:to_h) + end + + def class_based_resources_registered? + @resources.any? { |resource| resource.is_a?(Class) } || + @resource_templates.any? { |template| template.is_a?(Class) } end def list_resource_templates(request) diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb index 4a6ef5f..ffe742d 100644 --- a/test/mcp/resource_template_test.rb +++ b/test/mcp/resource_template_test.rb @@ -71,5 +71,140 @@ module MCP expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } assert_equal expected, resource_template.to_h[:annotations] end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + title "User Profile" + description "profile for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = ResourceTemplate.new( + uri_template: "users://{user_id}/profile", + name: "user_profile_template", + title: "User Profile", + description: "profile for a user", + mime_type: "application/json", + ) + + assert_equal instance.to_h, UserProfileTemplate.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_profile_template", UserProfileTemplate.name_value + end + + test "explicit resource_template_name wins over the class name" do + template_class = Class.new(UserProfileTemplate) do + resource_template_name "custom_template" + end + + assert_equal "custom_template", template_class.resource_template_name + assert_equal "custom_template", template_class.name_value + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserProfileTemplate) + + assert_nil subclass.uri_template + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.mime_type + end + + test ".contents raises NotImplementedError unless implemented" do + template_class = Class.new(ResourceTemplate) + + assert_raises(NotImplementedError) { template_class.contents(user_id: "42") } + end + + test ".match_uri extracts a single template variable" do + assert_equal({ user_id: "42" }, UserProfileTemplate.match_uri("users://42/profile")) + end + + test ".match_uri extracts multiple template variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "repos://{owner}/{repo}" + end + + assert_equal({ owner: "octo", repo: "sdk" }, template_class.match_uri("repos://octo/sdk")) + end + + test ".match_uri returns an empty hash for an exact match without variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "config://app" + end + + assert_equal({}, template_class.match_uri("config://app")) + end + + test ".match_uri returns nil when the URI does not match" do + assert_nil UserProfileTemplate.match_uri("users://42/settings") + assert_nil UserProfileTemplate.match_uri("posts://42/profile") + end + + test ".match_uri variables do not cross path separators" do + assert_nil UserProfileTemplate.match_uri("users://42/extra/profile") + end + + test ".match_uri returns nil when uri_template is not set" do + template_class = Class.new(ResourceTemplate) + + assert_nil template_class.match_uri("users://42/profile") + end + + test ".match_uri escapes regex metacharacters in literal parts" do + template_class = Class.new(ResourceTemplate) do + uri_template "file:///data.v1/{name}" + end + + assert_equal({ name: "foo" }, template_class.match_uri("file:///data.v1/foo")) + assert_nil template_class.match_uri("file:///dataXv1/foo") + end + + test ".match_uri treats unsupported RFC 6570 operators as literals" do + template_class = Class.new(ResourceTemplate) do + uri_template "search://items{?query}" + end + + assert_nil template_class.match_uri("search://items?query=foo") + assert_equal({}, template_class.match_uri("search://items{?query}")) + end + + test "re-setting uri_template invalidates the compiled pattern" do + template_class = Class.new(ResourceTemplate) do + uri_template "old://{id}" + end + assert_equal({ id: "1" }, template_class.match_uri("old://1")) + + template_class.uri_template("new://{id}") + + assert_nil template_class.match_uri("old://1") + assert_equal({ id: "2" }, template_class.match_uri("new://2")) + end + + test ".define creates a template class whose block implements contents" do + template_class = ResourceTemplate.define( + uri_template: "items://{item_id}", + name: "item_template", + mime_type: "text/plain", + ) do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: mime_type, text: "item #{item_id}")] + end + + assert_equal "items://{item_id}", template_class.uri_template + assert_equal "item_template", template_class.name_value + + contents = template_class.contents(item_id: "7") + assert_equal 1, contents.size + assert_equal "item 7", contents.first.text + end end end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index a721ad3..3c8ff1b 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -81,5 +81,111 @@ module MCP expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } assert_equal expected, resource.to_h[:annotations] end + + class UserGuideResource < Resource + uri "file:///docs/user_guide.md" + title "User Guide" + description "the user guide" + icons [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")] + mime_type "text/markdown" + annotations audience: ["user"], priority: 0.5 + size 2_048 + meta({ "example" => "value" }) + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "guide body")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = Resource.new( + uri: "file:///docs/user_guide.md", + name: "user_guide_resource", + title: "User Guide", + description: "the user guide", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + mime_type: "text/markdown", + annotations: Annotations.new(audience: ["user"], priority: 0.5), + size: 2_048, + meta: { "example" => "value" }, + ) + + assert_equal instance.to_h, UserGuideResource.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_guide_resource", UserGuideResource.name_value + end + + test "explicit resource_name wins over the class name" do + resource_class = Class.new(UserGuideResource) do + resource_name "custom_name" + end + + assert_equal "custom_name", resource_class.resource_name + assert_equal "custom_name", resource_class.name_value + end + + test "class-based .to_h omits keys that are not set" do + resource_class = Class.new(Resource) do + uri "file:///bare.txt" + resource_name "bare" + end + + assert_equal({ uri: "file:///bare.txt", name: "bare" }, resource_class.to_h) + end + + test "class-level annotations accepts an Annotations instance" do + annotations = Annotations.new(audience: ["assistant"]) + resource_class = Class.new(Resource) do + annotations annotations + end + + assert_same annotations, resource_class.annotations + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserGuideResource) + + assert_nil subclass.uri + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.icons + assert_nil subclass.mime_type + assert_nil subclass.annotations + assert_nil subclass.size + assert_nil subclass.meta + end + + test ".contents raises NotImplementedError unless implemented" do + resource_class = Class.new(Resource) + + assert_raises(NotImplementedError) { resource_class.contents } + end + + test ".define creates a resource class whose block implements contents" do + resource_class = Resource.define( + uri: "file:///defined.txt", + name: "defined_resource", + mime_type: "text/plain", + ) do + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "defined body")] + end + + assert_equal "file:///defined.txt", resource_class.uri + assert_equal "defined_resource", resource_class.name_value + + contents = resource_class.contents + assert_equal 1, contents.size + assert_equal "defined body", contents.first.text + end + + test ".define without a name omits :name from to_h" do + resource_class = Resource.define(uri: "file:///anonymous.txt") + + refute resource_class.to_h.key?(:name) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 417f352..df6ad9f 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -925,6 +925,196 @@ module MCP assert_instrumentation_data({ method: "resources/templates/list" }) end + class GreetingResource < Resource + uri "greeting://hello" + resource_name "greeting" + mime_type "text/plain" + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "hello")] + end + end + end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user_profile" + mime_type "text/plain" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + def read_resource_request(uri) + { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: uri }, + } + end + + test "#handle resources/read auto-routes to a class-based resource by exact URI" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resources: [GreetingResource], configuration: configuration) + + response = server.handle(read_resource_request("greeting://hello")) + + expected = [{ uri: "greeting://hello", mimeType: "text/plain", text: "hello" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ method: "resources/read", resource_uri: "greeting://hello" }) + end + + test "#handle resources/read wraps a single contents object returned by a class-based resource" do + resource = Resource.define(uri: "single://one", name: "single") do + Resource::TextContents.new(uri: "single://one", mime_type: "text/plain", text: "only one") + end + server = Server.new(name: "test_server", resources: [resource]) + + response = server.handle(read_resource_request("single://one")) + + expected = [{ uri: "single://one", mimeType: "text/plain", text: "only one" }] + assert_equal({ contents: expected }, response[:result]) + end + + test "#handle resources/read passes server_context to contents that opt in" do + received = nil + resource = Resource.define(uri: "ctx://resource", name: "ctx") do |server_context:| + received = server_context + [Resource::TextContents.new(uri: "ctx://resource", mime_type: "text/plain", text: "ctx")] + end + server = Server.new(name: "test_server", resources: [resource]) + + server.handle(read_resource_request("ctx://resource")) + + assert_instance_of ServerContext, received + end + + test "#handle resources/read auto-routes to a class-based resource template with extracted params" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate], configuration: configuration) + + response = server.handle(read_resource_request("users://42/profile")) + + expected = [{ uri: "users://42/profile", mimeType: "text/plain", text: "profile of 42" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ + method: "resources/read", + resource_uri: "users://42/profile", + resource_template: "users://{user_id}/profile", + }) + end + + test "#handle resources/read prefers an exact resource match over a template match" do + resource = Resource.define(uri: "users://42/profile", name: "pinned_profile") do + [Resource::TextContents.new(uri: "users://42/profile", mime_type: "text/plain", text: "pinned")] + end + server = Server.new(name: "test_server", resources: [resource], resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/profile")) + + assert_equal "pinned", response[:result][:contents].first[:text] + end + + test "#handle resources/read returns -32602 for an unknown URI when class-based resources are registered" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + response = server.handle(read_resource_request("greeting://unknown")) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: greeting://unknown", response[:error][:message]) + assert_equal({ uri: "greeting://unknown" }, response[:error][:data]) + end + + test "#handle resources/read returns -32602 for a non-matching URI when class-based templates are registered" do + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/settings")) + + assert_equal(-32602, response[:error][:code]) + assert_equal({ uri: "users://42/settings" }, response[:error][:data]) + end + + test "#handle resources/read keeps returning [] for unknown URIs when only instance-based resources are registered" do + server = Server.new(name: "test_server", resources: [@resource], resource_templates: [@resource_template]) + + response = server.handle(read_resource_request("unknown://resource")) + + assert_equal({ contents: [] }, response[:result]) + end + + test "#resources_read_handler overrides auto-routing for class-based resources" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + server.resources_read_handler do |request| + [{ uri: request[:uri], mimeType: "text/plain", text: "handler wins" }] + end + + response = server.handle(read_resource_request("greeting://hello")) + + assert_equal "handler wins", response[:result][:contents].first[:text] + end + + test "#handle resources/list and resources/templates/list render class-based and instance-based entries together" do + server = Server.new( + name: "test_server", + resources: [@resource, GreetingResource], + resource_templates: [@resource_template, UserProfileTemplate], + ) + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal [@resource.to_h, GreetingResource.to_h], list_response[:result][:resources] + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal [@resource_template.to_h, UserProfileTemplate.to_h], templates_response[:result][:resourceTemplates] + end + + test "#resources= rebuilds the resource index used for auto-routing" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + replacement = Resource.define(uri: "replacement://res", name: "replacement") do + [Resource::TextContents.new(uri: "replacement://res", mime_type: "text/plain", text: "replaced")] + end + server.resources = [replacement] + + response = server.handle(read_resource_request("replacement://res")) + assert_equal "replaced", response[:result][:contents].first[:text] + + stale_response = server.handle(read_resource_request("greeting://hello")) + assert_equal(-32602, stale_response[:error][:code]) + end + + test "#define_resource registers a resource served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource(uri: "defined://res", name: "defined", mime_type: "text/plain") do + [Resource::TextContents.new(uri: "defined://res", mime_type: "text/plain", text: "defined body")] + end + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal ["defined"], list_response[:result][:resources].map { |resource| resource[:name] } + + response = server.handle(read_resource_request("defined://res")) + assert_equal "defined body", response[:result][:contents].first[:text] + end + + test "#define_resource_template registers a template served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource_template(uri_template: "items://{item_id}", name: "item_template") do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: "text/plain", text: "item #{item_id}")] + end + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal ["item_template"], templates_response[:result][:resourceTemplates].map { |template| template[:name] } + + response = server.handle(read_resource_request("items://7")) + assert_equal "item 7", response[:result][:contents].first[:text] + end + test "#configure_logging_level returns empty hash on success" do response = @server.handle( { @@ -1556,6 +1746,20 @@ module MCP assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end + test "raises error if `title` of class-based resource is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + resource = Resource.define( + uri: "https://test_resource.invalid", + name: "test-resource", + title: "Test resource", + ) + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", resources: [resource], configuration: configuration) + end + assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + test "allows `$ref` in tool input schema with protocol version 2025-11-25" do tool = Tool.define( name: "ref_tool", From 887599090981c5a86238c868c5a0b0e6e9386308 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 8 Jul 2026 13:10:40 +0900 Subject: [PATCH 143/247] Handle non-object JSON-RPC messages without raising ## Motivation and Context A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised on a non-object value parsed from an incoming frame: - `JsonRpcHandler.handle` maps each element of a batch array through `process_request`, which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]` or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`, so the error escaped; over stdio it escaped the `StdioTransport#open` read loop. The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio. - `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`, which raises `NoMethodError` when the server emits a non-object frame. Only `JSON::ParserError` was rescued, so one malformed line from the server escaped. Both are robustness gaps: a malformed frame should be reported or skipped, not raise an unhandled exception out of the transport. - `process_request` now returns an `INVALID_REQUEST` error response for a non-object request before touching `request[:id]`, so a malformed batch element becomes a normal per-element error. - `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response, so an unexpected handling error still produces a JSON-RPC reply. - `read_response` skips a non-object frame the same way it skips a frame without an id. ## How Has This Been Tested? New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements (`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element, and `handle_json("[[]]")` returning an error instead of raising. A test in `test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb` has the server emit non-object frames before the response and asserts the client skips them and still returns the response. ## Breaking Changes None. A well-formed request, batch, or response is handled exactly as before; only a non-object message now yields a JSON-RPC error on the server or is skipped by the client instead of raising. --- lib/json_rpc_handler.rb | 17 +++++ lib/mcp/client/stdio.rb | 4 +- test/json_rpc_handler_test.rb | 31 ++++++++++ test/mcp/client/stdio_test.rb | 62 +++++++++++++++++++ .../server/transports/stdio_transport_test.rb | 21 +++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 2c6c836..9992ae7 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -63,12 +63,29 @@ module JsonRpcHandler message: "Parse error", data: "Invalid JSON", }) + rescue StandardError + # Last-resort guard so an unexpected handling error returns a JSON-RPC error + # instead of escaping to the transport loop. + response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INTERNAL_ERROR, + message: "Internal error", + }) end response&.to_json end def process_request(request, id_validation_pattern:, &method_finder) + # A batch element can be any JSON value; a non-object element cannot carry an id or a method, + # so reject it before the `request[:id]` access below. + unless request.is_a?(Hash) + return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_REQUEST, + message: "Invalid Request", + data: "Request must be an object", + }) + end + id = request[:id] error = if !valid_version?(request[:jsonrpc]) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index e835968..5bcf651 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -259,7 +259,9 @@ module MCP parsed = JSON.parse(line.strip) - next unless parsed.key?("id") + # A JSON-RPC message is an object; skip a non-object frame (array or scalar) + # the same way as a frame without an id. + next unless parsed.is_a?(Hash) && parsed.key?("id") return parsed if parsed["id"] == request_id end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 83a18df..e823e42 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -682,6 +682,37 @@ describe JsonRpcHandler do assert_equal 3, @response.first[:result] assert_equal(-32600, @response.last.dig(:error, :code)) end + + it "returns an invalid request error for a non-object batch element instead of raising" do + [[], [1], ["x"], [nil], [true]].each do |batch| + handle batch + + assert_equal(-32600, @response.dig(:error, :code)) + assert_equal "Invalid Request", @response.dig(:error, :message) + assert_nil @response[:id] + end + end + + it "handles a valid request followed by a non-object element without raising" do + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + [], + ] + + assert @response.is_a?(Array) + assert_equal 3, @response.first[:result] + assert_equal(-32600, @response.last.dig(:error, :code)) + assert_nil @response.last[:id] + end + + it "does not raise from handle_json when a batch element is not an object" do + result = handle_json("[[]]") + + assert_equal(-32600, @response.dig(:error, :code)) + refute_nil result + end end # 7 Examples diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 205fae4..1995222 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -146,6 +146,68 @@ module MCP stdout_write.close end + def test_send_request_skips_non_object_frames + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request + stdin_read.gets + + # Non-object frames are skipped like any other non-matching frame. + stdout_write.puts("[[]]") + stdout_write.puts("null") + stdout_write.flush + + # Then send the actual response + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: "test-id", + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_send_request_raises_error_when_process_exits stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index c9cda78..f8d81a0 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -133,6 +133,27 @@ module MCP end end + test "open handles a malformed JSON-RPC batch element and emits an error response" do + input = StringIO.new("[[]]\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + response = JSON.parse(output.string, symbolize_names: true) + assert_nil(response[:id]) + assert_equal(-32600, response[:error][:code]) + assert_equal("Invalid Request", response[:error][:message]) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + test "rejects duplicate initialize on the same stdio session with -32600" do first = { jsonrpc: "2.0", From 5b935eb031d07e627af2d037b662519e81571b21 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 11 Jul 2026 17:14:34 +0900 Subject: [PATCH 144/247] Perform SSE stream writes outside the session mutex ## Motivation and Context `StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`. The send paths wrote to an SSE stream while holding that lock: the targeted and broadcast branches of `send_notification`, the server-to-client `send_request`, and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream` (a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`. A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly, the server-side `write` waits until the socket buffer drains. Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking): new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions all wait behind one slow reader. The broadcast path holds the lock across every session, so one slow stream also delays delivery to the sessions after it. The cleanup paths already release the lock before touching a stream: they collect `streams_to_close` and call `close` outside `synchronize`. This change applies the same discipline to the writes. - Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers the pending response there), then releases the lock before writing. - A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`, which mutates `@sessions` under `@mutex` and closes the affected streams outside it. A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session, matching the previous behavior. - `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`; the return values (true/false for a session, the delivered count for a broadcast) are unchanged. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records, via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification, broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now. The existing send and cleanup tests still pass. ## Breaking Changes None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream. --- .../transports/streamable_http_transport.rb | 189 ++++++++++-------- .../streamable_http_transport_test.rb | 94 +++++++++ 2 files changed, 203 insertions(+), 80 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 2cedd64..fd45b33 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -196,80 +196,107 @@ module MCP } notification[:params] = params if params + if session_id + deliver_targeted_notification(notification, session_id, related_request_id) + else + deliver_broadcast_notification(notification) + end + end + + def deliver_targeted_notification(notification, session_id, related_request_id) + # JSON response mode returns a single JSON object as the POST response, + # so request-scoped notifications (e.g. progress, log) cannot be delivered + # alongside it. Session-scoped standalone notifications + # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE. + return false if @enable_json_response && related_request_id + streams_to_close = [] - result = @mutex.synchronize do - if session_id - # JSON response mode returns a single JSON object as the POST response, - # so request-scoped notifications (e.g. progress, log) cannot be delivered - # alongside it. Session-scoped standalone notifications - # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE. - next false if @enable_json_response && related_request_id + # Resolve the target stream under the lock, then write outside it: a stalled SSE reader + # must not block every other session that needs `@mutex`. + stream = @mutex.synchronize do + next unless (session = @sessions[session_id]) - # Send to specific session - if (session = @sessions[session_id]) - stream = active_stream(session, related_request_id: related_request_id) - end - next false unless stream + if session_expired?(session) + cleanup_and_collect_stream(session_id, streams_to_close) + next + end + + active_stream(session, related_request_id: related_request_id) + end + + close_streams(streams_to_close) + return false unless stream + + write_notification(stream, notification, session_id, related_request_id) + end + + def deliver_broadcast_notification(notification) + streams_to_close = [] + + # Snapshot the connected streams under the lock, then write to each outside it. + targets = @mutex.synchronize do + expired_session_ids = [] + + collected = @sessions.filter_map do |session_id, session| + next unless (stream = session[:get_sse_stream]) if session_expired?(session) - cleanup_and_collect_stream(session_id, streams_to_close) - next false + expired_session_ids << session_id + next end - begin - send_to_stream(stream, notification) - true - rescue *STREAM_WRITE_ERRORS => e - MCP.configuration.exception_reporter.call( - e, - { session_id: session_id, error: "Failed to send notification" }, - ) - if related_request_id && session[:post_request_streams]&.key?(related_request_id) - session[:post_request_streams].delete(related_request_id) - streams_to_close << stream - else - cleanup_and_collect_stream(session_id, streams_to_close) - end - false - end + [session_id, stream] + end + + expired_session_ids.each do |session_id| + cleanup_and_collect_stream(session_id, streams_to_close) + end + + collected + end + + close_streams(streams_to_close) + + targets.count do |session_id, stream| + write_notification(stream, notification, session_id, nil) + end + end + + # Writes a notification to an SSE stream without holding `@mutex`. On a write error, + # drops the broken stream and returns false; on success returns true. + def write_notification(stream, notification, session_id, related_request_id) + send_to_stream(stream, notification) + true + rescue *STREAM_WRITE_ERRORS => e + MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" }) + drop_broken_stream(session_id, stream, related_request_id) + false + end + + # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own; + # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs + # under `@mutex`, and the affected streams are closed outside it. + def drop_broken_stream(session_id, stream, related_request_id) + streams_to_close = [] + + @mutex.synchronize do + session = @sessions[session_id] + if related_request_id && session&.dig(:post_request_streams, related_request_id) + session[:post_request_streams].delete(related_request_id) + streams_to_close << stream else - # Broadcast to all connected SSE sessions - sent_count = 0 - failed_sessions = [] - - @sessions.each do |sid, session| - next unless (stream = session[:get_sse_stream]) - - if session_expired?(session) - failed_sessions << sid - next - end - - begin - send_to_stream(stream, notification) - sent_count += 1 - rescue *STREAM_WRITE_ERRORS => e - MCP.configuration.exception_reporter.call( - e, - { session_id: sid, error: "Failed to send notification" }, - ) - failed_sessions << sid - end - end - - # Clean up failed sessions - failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) } - - sent_count + cleanup_and_collect_stream(session_id, streams_to_close) end end - streams_to_close.each do |stream| + close_streams(streams_to_close) + end + + def close_streams(streams) + streams.each do |stream| close_stream_safely(stream) end - - result end # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and @@ -299,27 +326,25 @@ module MCP request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params - sent = false - - @mutex.synchronize do + # Register the pending response and resolve the stream under the lock, but perform + # the write outside it so a stalled reader cannot block every other session on `@mutex`. + stream = @mutex.synchronize do unless (session = @sessions[session_id]) raise "Session not found: #{session_id}." end @pending_responses[request_id] = { queue: queue, session_id: session_id } - if (stream = active_stream(session, related_request_id: related_request_id)) - begin - send_to_stream(stream, request) - sent = true - rescue *STREAM_WRITE_ERRORS - if related_request_id && session[:post_request_streams]&.key?(related_request_id) - session[:post_request_streams].delete(related_request_id) - close_stream_safely(stream) - else - cleanup_session_unsafe(session_id) - end - end + active_stream(session, related_request_id: related_request_id) + end + + sent = false + if stream + begin + send_to_stream(stream, request) + sent = true + rescue *STREAM_WRITE_ERRORS + drop_broken_stream(session_id, stream, related_request_id) end end @@ -1164,11 +1189,15 @@ module MCP end def send_keepalive_ping(session_id) - @mutex.synchronize do - if @sessions[session_id] && @sessions[session_id][:get_sse_stream] - send_ping_to_stream(@sessions[session_id][:get_sse_stream]) - end + # Resolve the stream under the lock, then write outside it so a stalled reader + # cannot block every other session on `@mutex`. + stream = @mutex.synchronize do + session = @sessions[session_id] + session && session[:get_sse_stream] end + return unless stream + + send_ping_to_stream(stream) rescue *STREAM_WRITE_ERRORS => e MCP.configuration.exception_reporter.call( e, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 8bf02ed..0bf51f1 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1335,6 +1335,62 @@ module MCP assert @transport.instance_variable_get(:@sessions).key?(session_id2) end + test "send_notification to a session writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + result = @transport.send_notification("test", { message: "hi" }, session_id: session_id) + + assert result + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "notification write must not hold @mutex" + end + + test "send_notification broadcast writes outside the mutex" do + session_id1 = initialize_test_session(id: "1") + session_id2 = initialize_test_session(id: "2") + writes1 = install_mutex_probe_stream(session_id1) + writes2 = install_mutex_probe_stream(session_id2) + + sent_count = @transport.send_notification("broadcast", { message: "hi" }, **{}) + + assert_equal 2, sent_count + assert_equal 0, writes1[:under_mutex], "broadcast write must not hold @mutex" + assert_equal 0, writes2[:under_mutex], "broadcast write must not hold @mutex" + end + + test "send_keepalive_ping writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + @transport.send(:send_keepalive_ping, session_id) + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex" + end + + test "send_request writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + result_queue = Queue.new + thread = Thread.new do + result_queue.push(@transport.send_request("ping", nil, session_id: session_id)) + end + + # Wait until the request has been written to the probe stream. + Thread.pass until writes[:total].positive? + + request_id = @transport.instance_variable_get(:@pending_responses).keys.first + @transport.send(:handle_response, { jsonrpc: "2.0", id: request_id, result: {} }, session_id: session_id) + + result_queue.pop + thread.join + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "server-to-client request write must not hold @mutex" + end + test "send_keepalive_ping handles Errno::ECONNRESET gracefully" do # Create and initialize a session. init_request = create_rack_request( @@ -5035,6 +5091,44 @@ module MCP private + def initialize_test_session(id: "init") + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: id }.to_json, + ) + @transport.handle_request(init_request)[1]["Mcp-Session-Id"] + end + + # Installs a stream that records, on every write, whether `@mutex` was held at that moment. + # `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]` + # means a blocking write is being performed under the lock. + def install_mutex_probe_stream(session_id, related_request_id: nil) + mutex = @transport.instance_variable_get(:@mutex) + writes = { total: 0, under_mutex: 0 } + stream = Object.new + stream.define_singleton_method(:write) do |_data| + writes[:total] += 1 + if mutex.try_lock + mutex.unlock + else + writes[:under_mutex] += 1 + end + end + stream.define_singleton_method(:flush) {} + stream.define_singleton_method(:close) {} + + session = @transport.instance_variable_get(:@sessions)[session_id] + if related_request_id + (session[:post_request_streams] ||= {})[related_request_id] = stream + else + session[:get_sse_stream] = stream + end + + writes + end + def create_rack_request(method, path, headers, body = nil) default_accept = case method when "POST" From abd1070cfae3992d04ef70e434b1f7f8f2bb305e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 11 Jul 2026 22:30:25 +0900 Subject: [PATCH 145/247] Validate Required Params for `initialize` and `resources/subscribe` ## Motivation and Context Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450 The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params, and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with these fields absent: `initialize` silently negotiated the server's default protocol version, and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params, matching the Python SDK, which rejects such requests during pydantic validation. The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler` and `resources_unsubscribe_handler`. A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version, as the spec's lifecycle section requires. ## How Has This Been Tested? Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding. ## Breaking Changes Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`, or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params instead of a success response. Such requests violate the MCP schema, but any code relying on the previous lenient behavior (for example, test harnesses sending bare `initialize` requests) must now include the required fields. --- lib/mcp/server.rb | 42 ++- test/initialize_params_test_helper.rb | 13 + .../server/transports/stdio_transport_test.rb | 3 + ...able_http_notification_integration_test.rb | 14 +- .../streamable_http_transport_test.rb | 287 +++++++++--------- test/mcp/server_roots_test.rb | 5 +- test/mcp/server_test.rb | 223 ++++++++++---- test/test_helper.rb | 1 + 8 files changed, 369 insertions(+), 219 deletions(-) create mode 100644 test/initialize_params_test_helper.rb diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 78d0774..2652c1d 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -531,6 +531,7 @@ module MCP when Methods::RESOURCES_READ build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE + validate_resource_subscription_params!(params) dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) {} when Methods::TOOLS_CALL @@ -605,6 +606,8 @@ module MCP end def init(params, session: nil) + validate_initialize_params!(params) + # MCP spec: the initialization phase MUST be the first interaction between client and server. # Reject duplicate `initialize` on an already-initialized session so the negotiated # client identity and capabilities cannot be silently overwritten. @@ -612,15 +615,13 @@ module MCP raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request) end - if params - if session - session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) - else - @client = params[:clientInfo] - @client_capabilities = params[:capabilities] - end - protocol_version = params[:protocolVersion] + if session + session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) + else + @client = params[:clientInfo] + @client_capabilities = params[:capabilities] end + protocol_version = params[:protocolVersion] negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) protocol_version @@ -649,6 +650,31 @@ module MCP }.compact end + def validate_resource_subscription_params!(params) + unless params.is_a?(Hash) && params[:uri].is_a?(String) + raise RequestHandlerError.new("Missing or invalid uri", params, error_type: :invalid_params) + end + end + + def validate_initialize_params!(params) + unless params.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) + end + + unless params[:protocolVersion].is_a?(String) + raise RequestHandlerError.new("Missing or invalid protocolVersion", params, error_type: :invalid_params) + end + + unless params[:capabilities].is_a?(Hash) + raise RequestHandlerError.new("Missing or invalid capabilities", params, error_type: :invalid_params) + end + + client_info = params[:clientInfo] + if !client_info.is_a?(Hash) || client_info[:name].nil? || client_info[:version].nil? + raise RequestHandlerError.new("Missing or invalid clientInfo", params, error_type: :invalid_params) + end + end + # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery. # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark # the session initialized, and responds regardless of capability declarations or initialization state, diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb new file mode 100644 index 0000000..6e6d6b2 --- /dev/null +++ b/test/initialize_params_test_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module InitializeParamsTestHelper + # `initialize` params satisfying the fields required by the MCP schema + # (protocolVersion, capabilities, and clientInfo). + def initialize_params(**overrides) + { + protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }.merge(overrides) + end +end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index c9cda78..084e008 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -108,6 +108,7 @@ module MCP id: "1", params: { protocolVersion: "2025-11-25", + capabilities: {}, clientInfo: { name: "stdio-client", version: "1.0" }, }, } @@ -140,6 +141,7 @@ module MCP id: "first", params: { protocolVersion: "2025-11-25", + capabilities: {}, clientInfo: { name: "original", version: "1.0" }, }, } @@ -149,6 +151,7 @@ module MCP id: "second", params: { protocolVersion: "2024-11-05", + capabilities: {}, clientInfo: { name: "intruder", version: "9.9" }, }, } diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 355078f..36bbca1 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -7,6 +7,8 @@ module MCP class Server module Transports class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase + include InitializeParamsTestHelper + setup do @server = Server.new( name: "test_server", @@ -23,7 +25,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -72,7 +74,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) session_id1 = init_response1[1]["Mcp-Session-Id"] @@ -81,7 +83,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) session_id2 = init_response2[1]["Mcp-Session-Id"] @@ -127,7 +129,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -163,7 +165,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -207,7 +209,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 8bf02ed..737f86b 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -7,6 +7,8 @@ module MCP class Server module Transports class StreamableHTTPTransportTest < ActiveSupport::TestCase + include InitializeParamsTestHelper + # A stream that buffers writes and remains readable after close. class TestStream def initialize @@ -52,7 +54,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -85,7 +87,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -136,7 +138,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -164,7 +166,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -192,7 +194,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -211,7 +213,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "first" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -221,7 +223,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, - { jsonrpc: "2.0", method: "initialize", id: "second" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "second", params: initialize_params }.to_json, ) duplicate_response = @transport.handle_request(duplicate_request) @@ -248,7 +250,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" }, - { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -267,7 +269,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "first" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, ) init_response = transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -279,7 +281,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, - { jsonrpc: "2.0", method: "initialize", id: "second" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "second", params: initialize_params }.to_json, ) duplicate_response = transport.handle_request(duplicate_request) @@ -318,7 +320,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - [{ jsonrpc: "2.0", method: "initialize", id: "batched" }].to_json, + [{ jsonrpc: "2.0", method: "initialize", id: "batched", params: initialize_params }].to_json, ) response = @transport.handle_request(request) @@ -336,7 +338,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -361,7 +363,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -399,7 +401,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -443,7 +445,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -592,7 +594,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -625,7 +627,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -686,7 +688,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -726,7 +728,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -759,7 +761,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -805,7 +807,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -841,7 +843,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -873,7 +875,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) @transport.handle_request(init_request) @@ -890,7 +892,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) session_id1 = init_response1[1]["Mcp-Session-Id"] @@ -900,7 +902,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) session_id2 = init_response2[1]["Mcp-Session-Id"] @@ -993,7 +995,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1029,7 +1031,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) session_id1 = init_response1[1]["Mcp-Session-Id"] @@ -1038,7 +1040,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) session_id2 = init_response2[1]["Mcp-Session-Id"] @@ -1093,7 +1095,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1130,7 +1132,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1167,7 +1169,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1198,7 +1200,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1237,7 +1239,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1278,7 +1280,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) session_id1 = init_response1[1]["Mcp-Session-Id"] @@ -1287,7 +1289,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "2" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "2", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) session_id2 = init_response2[1]["Mcp-Session-Id"] @@ -1341,7 +1343,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1407,7 +1409,7 @@ module MCP "POST", "/", { "HTTP_ACCEPT" => "application/json, text/event-stream" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1428,7 +1430,7 @@ module MCP "CONTENT_TYPE" => "text/plain", "HTTP_ACCEPT" => "application/json, text/event-stream", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1443,7 +1445,7 @@ module MCP "CONTENT_TYPE" => "application/json; charset=utf-8", "HTTP_ACCEPT" => "application/json, text/event-stream", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1455,7 +1457,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1475,7 +1477,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1494,7 +1496,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "text/event-stream", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1513,7 +1515,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1528,7 +1530,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json;q=0.9, text/event-stream;q=0.8", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1543,7 +1545,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "*/*", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1558,7 +1560,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "Application/JSON, Text/Event-Stream", }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1570,7 +1572,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1594,7 +1596,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1620,7 +1622,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1646,7 +1648,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1670,7 +1672,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -1697,7 +1699,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "1900-01-01", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) response = @transport.handle_request(request) @@ -1720,7 +1722,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION }, + params: initialize_params, }.to_json, ) @@ -1746,7 +1748,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: older_version }, + params: initialize_params(protocolVersion: older_version), }.to_json, ) @@ -1761,7 +1763,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1794,7 +1796,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1822,7 +1824,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1847,7 +1849,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1887,7 +1889,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1915,7 +1917,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1943,7 +1945,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1969,7 +1971,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -1989,7 +1991,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -2044,7 +2046,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = stateless_transport.handle_request(init_request) assert_nil init_response[1]["Mcp-Session-Id"] @@ -2180,10 +2182,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: i + 1, - params: { - protocolVersion: "2025-11-25", - clientInfo: { name: "client-#{i}", version: "1.0" }, - }, + params: initialize_params(clientInfo: { name: "client-#{i}", version: "1.0" }), }.to_json, ) response = stateless_transport.handle_request(request) @@ -2249,7 +2248,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -2295,7 +2294,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -2332,7 +2331,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) response = transport.handle_request(request) @@ -2350,7 +2349,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) response = transport.handle_request(request) @@ -2371,7 +2370,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "*/*", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) response = transport.handle_request(request) @@ -2400,7 +2399,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + params: initialize_params, }.to_json, ) init_response = transport.handle_request(init_request) @@ -2452,7 +2451,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + params: initialize_params, }.to_json, ) init_response = transport.handle_request(init_request) @@ -2561,7 +2560,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + params: initialize_params, }.to_json, ) init_response = transport.handle_request(init_request) @@ -2660,7 +2659,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + params: initialize_params, }.to_json, ) init_response = transport.handle_request(init_request) @@ -2706,7 +2705,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "4567" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "4567", params: initialize_params }.to_json, ) @transport.define_singleton_method(:extract_session_id) do |_request| @@ -2756,7 +2755,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -2774,7 +2773,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -2842,7 +2841,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init-a" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init-a", params: initialize_params }.to_json, ) resp_a = @transport.handle_request(init_a) session_a = resp_a[1]["Mcp-Session-Id"] @@ -2851,7 +2850,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init-b" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init-b", params: initialize_params }.to_json, ) resp_b = @transport.handle_request(init_b) session_b = resp_b[1]["Mcp-Session-Id"] @@ -2913,7 +2912,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -2953,7 +2952,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3021,7 +3020,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3089,7 +3088,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "init", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "test" } }, + params: initialize_params, }.to_json, ) init_response = transport.handle_request(init_request) @@ -3147,7 +3146,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3206,7 +3205,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3250,7 +3249,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3349,7 +3348,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3435,7 +3434,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3492,7 +3491,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3545,7 +3544,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3574,7 +3573,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -3605,7 +3604,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3639,7 +3638,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3668,7 +3667,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3696,7 +3695,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3729,7 +3728,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3760,7 +3759,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3795,7 +3794,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -3856,7 +3855,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) # A freshly created session is well within the timeout, so normal use is unaffected. session_id = transport.handle_request(init_request)[1]["Mcp-Session-Id"] @@ -3896,7 +3895,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) assert_equal(200, transport.handle_request(request)[0]) end @@ -3905,7 +3904,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) response = transport.handle_request(overflow) @@ -3924,7 +3923,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, )) end @@ -3981,7 +3980,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -4002,7 +4001,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -4025,7 +4024,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_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"] @@ -4065,7 +4064,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) @transport.handle_request(init_request) @@ -4121,7 +4120,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "1", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] @@ -4134,7 +4133,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "2", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] @@ -4197,7 +4196,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "1", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] @@ -4210,7 +4209,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "2", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] @@ -4271,10 +4270,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "1", - params: { - protocolVersion: "2025-11-25", - clientInfo: { name: "alpha", version: "1.0" }, - }, + params: initialize_params(clientInfo: { name: "alpha", version: "1.0" }), }.to_json, ) session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] @@ -4288,10 +4284,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "2", - params: { - protocolVersion: "2025-11-25", - clientInfo: { name: "beta", version: "2.0" }, - }, + params: initialize_params(clientInfo: { name: "beta", version: "2.0" }), }.to_json, ) session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] @@ -4315,7 +4308,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "1", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] @@ -4328,7 +4321,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: "2", - params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] @@ -4378,7 +4371,7 @@ module MCP env = { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/", - "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init-1" }.to_json), + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init-1", params: initialize_params }.to_json), "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", } @@ -4407,7 +4400,7 @@ module MCP init_env = { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/", - "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json), + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json), "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", } @@ -4432,7 +4425,7 @@ module MCP init_env = { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/", - "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json), + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json), "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", } @@ -4458,7 +4451,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init-frozen" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init-frozen", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) session_id = init_response[1]["Mcp-Session-Id"] @@ -4511,7 +4504,7 @@ module MCP test "accepts a request body at the max_request_bytes boundary" do transport = StreamableHTTPTransport.new(@server, max_request_bytes: 4096) - body = { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json + body = { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json assert_operator body.bytesize, :<=, 4096 request = create_rack_request( @@ -4609,7 +4602,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = transport.handle_request(request) @@ -4625,7 +4618,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = transport.handle_request(request) @@ -4643,7 +4636,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://anywhere.example.com", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = @transport.handle_request(request) @@ -4659,7 +4652,7 @@ module MCP "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => "http://localhost:3000", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = @transport.handle_request(request) @@ -4675,7 +4668,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "evil.example.com", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, headers, body = @transport.handle_request(request) @@ -4696,7 +4689,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = transport.handle_request(request) @@ -4715,7 +4708,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "LOCALHOST:3000", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = @transport.handle_request(request) @@ -4732,7 +4725,7 @@ module MCP "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = transport.handle_request(request) @@ -4748,13 +4741,13 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) rejected = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) assert_equal(200, transport.handle_request(allowed).first) @@ -4774,7 +4767,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match") @@ -4790,7 +4783,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected") @@ -4816,7 +4809,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = @transport.handle_request(request) @@ -4834,7 +4827,7 @@ module MCP "HTTP_HOST" => "evil.example.com", "HTTP_ORIGIN" => "https://evil.example.com", }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) status, = transport.handle_request(request) @@ -4855,7 +4848,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] @@ -4886,7 +4879,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] @@ -4923,7 +4916,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] @@ -4956,7 +4949,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] refute_nil(session_id) @@ -4998,7 +4991,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index c887398..b75a481 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -87,9 +87,9 @@ module MCP assert_equal({ roots: { listChanged: true } }, server.client_capabilities) end - test "init stores nil when client sends no capabilities" do + test "init rejects params without capabilities and stores nothing" do server = Server.new(name: "test", version: "1.0") - server.handle({ + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, @@ -99,6 +99,7 @@ module MCP }, }) + assert_equal(-32602, response[:error][:code]) assert_nil server.client_capabilities end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index df6ad9f..b41f0ad 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -5,6 +5,7 @@ require "test_helper" module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper setup do @tool = Tool.define( name: "test_tool", @@ -151,7 +152,7 @@ module MCP refute_predicate session, :initialized? # `initialize` must still succeed afterwards. response = @server.handle( - { jsonrpc: "2.0", method: "initialize", id: 2, params: { clientInfo: { name: "c", version: "1" } } }, + { jsonrpc: "2.0", method: "initialize", id: 2, params: initialize_params }, session: session, ) refute_nil response[:result] @@ -176,6 +177,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = @server.handle(request) @@ -204,7 +206,7 @@ module MCP } assert_equal expected_result, response - assert_instrumentation_data({ method: "initialize" }) + assert_instrumentation_data({ method: "initialize", client: { name: "test-client", version: "1.0.0" } }) end test "#handle initialize result carries declared capability extensions" do @@ -216,7 +218,7 @@ module MCP }, ) - response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) assert_equal( { "com.example/feature" => { enabled: true } }, @@ -230,7 +232,7 @@ module MCP capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) server = Server.new(name: "extensions_test", capabilities: capabilities) - response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) assert_equal( { @@ -247,10 +249,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - clientInfo: { name: "test_client", version: "1.0.0" }, - capabilities: { extensions: extensions }, - }, + params: initialize_params(capabilities: { extensions: extensions }), } @server.handle(request) @@ -265,10 +264,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - clientInfo: { name: "test_client", version: "1.0.0" }, - capabilities: { extensions: extensions }, - }, + params: initialize_params(capabilities: { extensions: extensions }), } @server.handle(request, session: session) @@ -282,9 +278,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - clientInfo: client_info, - }, + params: initialize_params(clientInfo: client_info), } @server.handle(request) @@ -297,9 +291,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - clientInfo: client_info, - }, + params: initialize_params(clientInfo: client_info), } @server.handle(initialize_request) @@ -319,7 +311,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { clientInfo: { name: "original", version: "1.0" } }, + params: initialize_params(clientInfo: { name: "original", version: "1.0" }), } first_response = @server.handle(first_request, session: session) refute_nil first_response[:result] @@ -328,7 +320,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 2, - params: { clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05" }, + params: initialize_params(clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05"), } second_response = @server.handle(second_request, session: session) @@ -337,6 +329,59 @@ module MCP assert_equal({ name: "original", version: "1.0" }, session.client) end + test "#handle initialize with empty params returns -32602 and does not initialize the session" do + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }, session: session) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + refute_predicate session, :initialized? + end + + test "#handle initialize without params returns -32602" do + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + end + + test "#handle initialize with a missing required field returns -32602" do + [:protocolVersion, :capabilities, :clientInfo].each do |field| + params = initialize_params.tap { |hash| hash.delete(field) } + + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: params }) + + assert_equal(-32602, response[:error][:code], "expected -32602 when #{field} is missing") + assert_includes response[:error][:data], field.to_s + end + end + + test "#handle initialize with clientInfo missing name or version returns -32602" do + [{ name: "client-without-version" }, { version: "1.0.0" }].each do |client_info| + response = @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(clientInfo: client_info), + }) + + assert_equal(-32602, response[:error][:code]) + assert_includes response[:error][:data], "clientInfo" + end + end + + test "#handle initialize with an unsupported protocolVersion still negotiates the fallback version" do + response = @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "1999-01-01"), + }) + + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + end + test "instrumentation data does not include client key when no clientInfo provided" do request = { jsonrpc: "2.0", @@ -1540,6 +1585,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = @server.handle(request) @@ -1552,6 +1598,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1567,6 +1614,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1580,6 +1628,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1592,6 +1641,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1607,6 +1657,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }] @@ -1620,6 +1671,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1632,6 +1684,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -1905,6 +1958,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params(protocolVersion: "1999-01-01"), } response = server.handle(request) @@ -1918,9 +1972,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - protocolVersion: "2025-06-18", - }, + params: initialize_params(protocolVersion: "2025-06-18"), } response = server.handle(request) @@ -1934,9 +1986,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - protocolVersion: "1999-01-01", - }, + params: initialize_params(protocolVersion: "1999-01-01"), } response = server.handle(request) @@ -1954,9 +2004,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - protocolVersion: "2025-06-18", - }, + params: initialize_params(protocolVersion: "2025-06-18"), } response = server.handle(request) @@ -1972,9 +2020,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - protocolVersion: "2025-03-26", - }, + params: initialize_params(protocolVersion: "2025-03-26"), } response = server.handle(request) @@ -1990,9 +2036,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, - params: { - protocolVersion: "2024-11-05", - }, + params: initialize_params(protocolVersion: "2024-11-05"), } response = server.handle(request) @@ -2381,7 +2425,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2419,7 +2463,7 @@ module MCP { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2457,7 +2501,7 @@ module MCP { completion: { values: ["file:///src", "file:///spec"], hasMore: false } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2500,7 +2544,7 @@ module MCP { completion: { values: ["flask"], hasMore: false } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) server.handle({ @@ -2529,7 +2573,7 @@ module MCP { completion: { values: (1..150).map(&:to_s), hasMore: false } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2556,7 +2600,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2578,7 +2622,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2600,7 +2644,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2624,7 +2668,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2646,7 +2690,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2677,7 +2721,7 @@ module MCP { completion: { values: ["file:///README.md"], hasMore: false } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2708,7 +2752,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2731,7 +2775,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2759,7 +2803,7 @@ module MCP nil end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2794,7 +2838,7 @@ module MCP { "completion" => { "values" => ["alpha", "beta"], "hasMore" => true } } end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2818,7 +2862,7 @@ module MCP capabilities: { completions: {} }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2837,7 +2881,7 @@ module MCP prompts: [], ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2860,7 +2904,7 @@ module MCP capabilities: { resources: { subscribe: true } }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2886,7 +2930,7 @@ module MCP capabilities: { resources: { subscribe: true } }, ) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2918,7 +2962,7 @@ module MCP {} end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2951,7 +2995,7 @@ module MCP {} end - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ @@ -2972,6 +3016,73 @@ module MCP assert_equal "https://example.com/resource", received_params[:uri] end + test "#handle resources/subscribe without uri returns -32602" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "uri" + end + + test "#handle resources/unsubscribe without uri returns -32602" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/unsubscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + end + + test "#handle resources/subscribe without uri does not invoke a custom handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + handler_called = false + server.resources_subscribe_handler do |_params| + handler_called = true + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + refute handler_called + end + test "tools/call with no args" do server = Server.new(tools: [@tool_with_no_args]) diff --git a/test/test_helper.rb b/test/test_helper.rb index b89bffa..4de7ffd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,3 +22,4 @@ require "active_support/test_case" require "sorbet-runtime" if RUBY_VERSION >= "3.0" require_relative "instrumentation_test_helper" +require_relative "initialize_params_test_helper" From ac12f9152541f6032ddeb58f18d723ae4af5bce9 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 12 Jul 2026 01:51:24 +0900 Subject: [PATCH 146/247] Send required `initialize` params in the mutex probe test helper ## Motivation and Context `bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`: ``` NoMethodError: undefined method '[]=' for nil streamable_http_transport_test.rb:5119 (install_mutex_probe_stream) ``` This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449, merged after but developed before it, added the "writes outside the mutex" tests, whose `initialize_test_session` helper sends an `initialize` request with no params. That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing the probe stream raises `NoMethodError`. The fix adds `params: initialize_params` to the helper's request body, matching the pattern used by every other `initialize` request in the file via `InitializeParamsTestHelper`. ## How Has This Been Tested? `bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses. The four previously failing tests are the regression coverage. ## Breaking Changes None. Test-only change. --- test/mcp/server/transports/streamable_http_transport_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 5cb2b84..a285f6f 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5089,7 +5089,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: id }.to_json, + { jsonrpc: "2.0", method: "initialize", id: id, params: initialize_params }.to_json, ) @transport.handle_request(init_request)[1]["Mcp-Session-Id"] end From 6ddcc4986502e78aa9a4c45450ff81751acf22c8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 19 Jun 2026 03:10:54 +0900 Subject: [PATCH 147/247] 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. --- README.md | 6 ++ lib/mcp/server_context.rb | 12 +++ lib/mcp/server_session.rb | 40 +++++++++ .../streamable_http_transport_test.rb | 61 ++++++++++++++ test/mcp/server_context_test.rb | 81 +++++++++++++++++++ test/mcp/server_elicitation_test.rb | 49 +++++++++++ test/mcp/server_roots_test.rb | 28 ++++++- test/mcp/server_sampling_test.rb | 62 +++++++++++--- 8 files changed, 323 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 59e307f..64398b5 100644 --- a/README.md +++ b/README.md @@ -1172,6 +1172,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 92b1abd..dc5b20a 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -54,6 +54,9 @@ module MCP 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 @@ module MCP # 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 @@ module MCP # 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 @@ module MCP # 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 502c616..754c1d0 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -98,11 +98,17 @@ module MCP 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 @@ module MCP 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 @@ module MCP 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 @@ module MCP # 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 a285f6f..d36e1b0 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -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( diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 44fa12e..9125805 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -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 diff --git a/test/mcp/server_elicitation_test.rb b/test/mcp/server_elicitation_test.rb index 66a70f4..03f3609 100644 --- a/test/mcp/server_elicitation_test.rb +++ b/test/mcp/server_elicitation_test.rb @@ -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", diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index b75a481..f8f31f9 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -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 diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index dd12356..9c6985b 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -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, From 1d8db69df07baee71bbb99ad410c1b6e0f3e2971 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 12 Jul 2026 02:22:32 +0900 Subject: [PATCH 148/247] Release 0.24.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. One of the requirements for the official SDK to move from Tier 3 to Tier 2 is reaching v1.0, and that milestone is now quite close. The main considerations for v1.0 are API stability and minimizing breaking changes. Based on the work in my local branch, a path toward Tier 1 is already becoming clear from my perspective, and this release is part of the work to prepare for Tier 2. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d8144..8d0c32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.24.0] - 2026-07-12 + +### Added + +- Support SSE reconnection per SEP-1699 in the HTTP client transport (#426) +- Validate the RFC 9207 `iss` authorization response parameter per SEP-2468 (#431) +- Support `private_key_jwt` client authentication for the `client_credentials` grant (#432) +- Recognize multi round-trip `input_required` results per SEP-2322 (#433) +- Add opt-in `ttlMs` / `cacheScope` cache hints to List and Read results per SEP-2549 (#436) +- Add `server/discover` and stateless lifecycle error codes per SEP-2575 (#438) +- Associate server-to-client requests with the originating client request per SEP-2260 (#440) +- Add server-side MCP Apps helpers per SEP-1865 (#441) +- Support client-side elicitation with SEP-1034 schema defaults (#443) +- Support class-based Resource and ResourceTemplate definitions (#447) + +### Changed + +- Bind stored client credentials to the authorization server issuer per SEP-2352 (#439) + +### Fixed + +- Handle non-object JSON-RPC messages without raising (#448) +- Perform SSE stream writes outside the session mutex (#449) +- Validate required params for `initialize` and `resources/subscribe` (#451) + ## [0.23.0] - 2026-07-07 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 0e2618f..57b753e 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.23.0" + VERSION = "0.24.0" end From 145a6b6230e7b2335a82338ac4ba1c1d8ffc84b6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 12 Jun 2026 23:56:01 +0900 Subject: [PATCH 149/247] Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant ## Motivation and Context The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990, accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization: the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed its two core checks (the Ruby client ran the authorization-code flow instead). This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design: - New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint (`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer, `resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK. - New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores, and tests all plug in the same way (the TypeScript provider's assertion callback works identically). The MCP AS credentials are stored as `client_secret_basic` client information. - `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts the `jwt-bearer` grant through the existing `post_to_token_endpoint`. - The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at `idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients), and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? - New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`, non-2xx responses, unparseable bodies, and non-object JSON. - New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface: stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider), and storage delegation. - New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials, with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience` and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted. ## Breaking Changes None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers that declare `authorization_flow :jwt_bearer`. --- README.md | 37 ++++++ conformance/client.rb | 25 +++- conformance/expected_failures.yml | 4 +- lib/mcp/client/oauth.rb | 5 +- .../client/oauth/cross_app_access_provider.rb | 80 +++++++++++++ lib/mcp/client/oauth/flow.rb | 35 +++++- lib/mcp/client/oauth/id_jag_token_exchange.rb | 101 +++++++++++++++++ .../oauth/cross_app_access_provider_test.rb | 94 +++++++++++++++ test/mcp/client/oauth/flow_test.rb | 47 ++++++++ .../oauth/id_jag_token_exchange_test.rb | 107 ++++++++++++++++++ 10 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 lib/mcp/client/oauth/cross_app_access_provider.rb create mode 100644 lib/mcp/client/oauth/id_jag_token_exchange.rb create mode 100644 test/mcp/client/oauth/cross_app_access_provider_test.rb create mode 100644 test/mcp/client/oauth/id_jag_token_exchange_test.rb diff --git a/README.md b/README.md index 390dd8f..1947ce6 100644 --- a/README.md +++ b/README.md @@ -2505,6 +2505,43 @@ Keyword arguments: - `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`. - `scope`, `storage`: Optional, same meaning as on `Provider`. +##### Cross-App Access (JWT Bearer) Grant + +For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. +The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG +to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. +Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. + +`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into +an enterprise secret store or a test double without changing the transport wiring. + +```ruby +provider = MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: "my-mcp-client", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + assertion_provider: ->(audience:, resource:) { + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: "https://idp.example.com/token", + id_token: ENV.fetch("IDP_ID_TOKEN"), + client_id: "my-idp-client", + audience: audience, + resource: resource, + ) + }, + # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. +- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. + `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). + Passing both through to `IDJAGTokenExchange.request` covers the common case. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + ##### Communication Security When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, diff --git a/conformance/client.rb b/conformance/client.rb index 32722c4..42a2ce1 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -87,6 +87,27 @@ def build_client_credentials_provider(context) end end +# Builds a SEP-990 Cross-App Access provider: the harness injects an IdP ID token plus the IdP token endpoint +# via context; the assertion provider exchanges them for an ID-JAG (RFC 8693), which the flow then presents to +# the MCP authorization server with the jwt-bearer grant. +def build_cross_app_access_provider(context) + assertion_provider = ->(audience:, resource:) do + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: context["idp_token_endpoint"], + id_token: context["idp_id_token"], + client_id: context["idp_client_id"], + audience: audience, + resource: resource, + ) + end + + MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + assertion_provider: assertion_provider, + ) +end + # Builds an OAuth provider that drives the authorization code + PKCE + DCR flow # non-interactively against the conformance test's auth server. The conformance # `/authorize` endpoint redirects synchronously to `redirect_uri` with @@ -129,7 +150,9 @@ def build_oauth_provider(context, scenario:) end def build_provider_for(scenario, context) - if scenario.start_with?("auth/client-credentials") + if context["idp_id_token"] + build_cross_app_access_provider(context) + elsif scenario.start_with?("auth/client-credentials") build_client_credentials_provider(context) else build_oauth_provider(context, scenario: scenario) diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 4ee1e74..0bbbbfb 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,4 +1,2 @@ server: [] -client: - # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/cross-app-access-complete-flow +client: [] diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index 6d1ab3f..6e10000 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -8,12 +8,15 @@ require_relative "oauth/storage_backed_provider" require_relative "oauth/jwt_client_assertion" require_relative "oauth/provider" require_relative "oauth/client_credentials_provider" +require_relative "oauth/id_jag_token_exchange" +require_relative "oauth/cross_app_access_provider" module MCP class Client # OAuth client support for the MCP Authorization spec (PRM discovery, # Authorization Server metadata discovery, Dynamic Client Registration, - # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant). + # OAuth 2.1 Authorization Code + PKCE, the client_credentials grant, + # and the SEP-990 Enterprise Managed Authorization jwt-bearer grant). # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization module OAuth end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb new file mode 100644 index 0000000..af5b599 --- /dev/null +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"): + # the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and + # presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with + # `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`. + # + # Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come + # from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double. + # + # Required keyword arguments: + # + # - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server. + # - `client_secret` - String shared secret for `client_secret_basic`. + # - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to + # present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL; + # pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token. + # + # Optional keyword arguments: + # + # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and + # the Protected Resource Metadata do not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. + # Defaults to an `InMemoryStorage`. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + class CrossAppAccessProvider + include StorageBackedProvider + + # Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs. + class InvalidConfigurationError < ArgumentError; end + + attr_reader :scope, :storage + + def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil) + if blank?(client_id) + raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." + end + + if blank?(client_secret) + raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic." + end + + unless assertion_provider.respond_to?(:call) + raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." + end + + @assertion_provider = assertion_provider + @scope = scope + @storage = storage || InMemoryStorage.new + @storage.save_client_information( + "client_id" => client_id, + "client_secret" => client_secret, + "token_endpoint_auth_method" => "client_secret_basic", + ) + end + + # See `Provider#authorization_flow`. + def authorization_flow + :jwt_bearer + end + + # Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience + # and resource resolved during discovery. + def jwt_bearer_assertion(audience:, resource:) + @assertion_provider.call(audience: audience, resource: resource) + end + + private + + def blank?(value) + value.nil? || (value.is_a?(String) && value.strip.empty?) + end + end + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 021192b..32669f2 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -51,8 +51,11 @@ module MCP as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) - if provider_authorization_flow == :client_credentials + case provider_authorization_flow + when :client_credentials return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) + when :jwt_bearer + return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) end ensure_pkce_supported!(as_metadata) @@ -154,6 +157,36 @@ module MCP "refusing to send them to the current authorization server (SEP-2352)." end + # Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension: + # the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`), + # which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery + # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request. + # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated. + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:) + client_info = @provider.client_information + unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") + raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." + end + + assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource) + if assertion.nil? || assertion.to_s.empty? + raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion." + end + + form = { + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" => assertion, + } + effective_scope = resolve_scope(scope: scope, prm: prm) + form["scope"] = effective_scope if effective_scope + form["resource"] = resource if resource + + tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + @provider.save_tokens(tokens) + :authorized + end + # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6). # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security # checks before talking to it. diff --git a/lib/mcp/client/oauth/id_jag_token_exchange.rb b/lib/mcp/client/oauth/id_jag_token_exchange.rb new file mode 100644 index 0000000..0e21140 --- /dev/null +++ b/lib/mcp/client/oauth/id_jag_token_exchange.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "json" +require "uri" + +module MCP + class Client + module OAuth + # RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into + # an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990). + # The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with + # the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`). + # Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + # - https://www.rfc-editor.org/rfc/rfc8693 + module IDJAGTokenExchange + # Raised when the identity provider's token exchange fails or returns something other than an ID-JAG. + class ExchangeError < StandardError; end + + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" + ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag" + + class << self + # Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string. + # + # @param token_endpoint [String] The identity provider's token endpoint. + # @param id_token [String] The IdP-issued ID token (the subject token). + # @param client_id [String] The client's identifier at the IdP. + # @param audience [String] The MCP authorization server's issuer identifier. + # @param resource [String] The canonical MCP server URL (RFC 8707). + # @param http_client [Object, nil] Faraday-compatible client; built lazily by default. + def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil) + http_client ||= default_http_client + + response = begin + http_client.post(token_endpoint) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + req.body = URI.encode_www_form( + "grant_type" => GRANT_TYPE, + "subject_token" => id_token, + "subject_token_type" => ID_TOKEN_TYPE, + "requested_token_type" => ID_JAG_TOKEN_TYPE, + "audience" => audience, + "resource" => resource, + "client_id" => client_id, + ) + end + rescue Faraday::Error => e + raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + raise ExchangeError, "Identity provider token exchange returned status #{response.status}." + end + + parse_id_jag(response) + end + + private + + def parse_id_jag(response) + body = response.body.is_a?(String) ? response.body : response.body.to_s + parsed = begin + JSON.parse(body) + rescue JSON::ParserError => e + raise ExchangeError, "Failed to parse token exchange response: #{e.message}." + end + + unless parsed.is_a?(Hash) + raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})." + end + + issued_token_type = parsed["issued_token_type"] + unless issued_token_type == ID_JAG_TOKEN_TYPE + raise ExchangeError, + "Token exchange did not issue an ID-JAG " \ + "(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})." + end + + assertion = parsed["access_token"] + if assertion.nil? || assertion.to_s.empty? + raise ExchangeError, "Token exchange response is missing `access_token`." + end + + assertion + end + + def default_http_client + require "faraday" + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + end + end + end + end + end + end +end diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb new file mode 100644 index 0000000..deb7e4b --- /dev/null +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class CrossAppAccessProviderTest < Minitest::Test + def build_provider(assertion_provider: ->(**) { "id-jag" }) + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: assertion_provider, + ) + end + + def test_initialize_stores_credentials_with_basic_auth_method + provider = build_provider + + info = provider.client_information + assert_equal("xaa-client", info["client_id"]) + assert_equal("xaa-secret", info["client_secret"]) + assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + end + + def test_authorization_flow_is_jwt_bearer + assert_equal(:jwt_bearer, build_provider.authorization_flow) + end + + def test_jwt_bearer_assertion_passes_audience_and_resource_through + received = nil + provider = build_provider( + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + assertion = provider.jwt_bearer_assertion( + audience: "https://auth.example.com", + resource: "https://srv.example.com/mcp", + ) + + assert_equal("id-jag-assertion", assertion) + assert_equal( + { audience: "https://auth.example.com", resource: "https://srv.example.com/mcp" }, + received, + ) + end + + def test_initialize_rejects_missing_client_id + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: " ", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + ) + end + end + + def test_initialize_rejects_missing_client_secret + # SEP-990 authenticates the jwt-bearer grant with client_secret_basic. + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: nil, + assertion_provider: ->(**) { "id-jag" }, + ) + end + end + + def test_initialize_rejects_non_callable_assertion_provider + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: "not callable", + ) + end + end + + def test_token_helpers_delegate_to_storage + provider = build_provider + provider.save_tokens("access_token" => "xaa-token") + + assert_equal("xaa-token", provider.access_token) + provider.clear_tokens! + assert_nil(provider.tokens) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index c05080e..f27679b 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -253,6 +253,53 @@ module MCP end end + def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider + # SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at + # the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved. + received = nil + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + # The assertion audience is the validated AS issuer; the resource is the canonical MCP server URL. + assert_equal({ audience: @auth_base, resource: "https://srv.example.com/mcp" }, received) + assert_not_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + expected_basic = "Basic " + Base64.strict_encode64("xaa-client:xaa-secret") + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["resource"] == "https://srv.example.com/mcp" && + !form.key?("code") && + !form.key?("code_verifier") && + req.headers["Authorization"] == expected_basic + end + end + + def test_run_jwt_bearer_raises_when_assertion_provider_returns_nothing + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) {}, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned no ID-JAG/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_run_uses_authorization_code_grant_for_default_provider # A standard `Provider` declares `authorization_flow == :authorization_code`, # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. diff --git a/test/mcp/client/oauth/id_jag_token_exchange_test.rb b/test/mcp/client/oauth/id_jag_token_exchange_test.rb new file mode 100644 index 0000000..a5f6914 --- /dev/null +++ b/test/mcp/client/oauth/id_jag_token_exchange_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" +require "faraday" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class IDJAGTokenExchangeTest < Minitest::Test + def setup + WebMock.enable! + @token_endpoint = "https://idp.example.com/token" + end + + def teardown + WebMock.reset! + end + + def request_exchange + IDJAGTokenExchange.request( + token_endpoint: @token_endpoint, + id_token: "idp-id-token", + client_id: "idp-client", + audience: "https://auth.example.com", + resource: "https://srv.example.com/mcp", + ) + end + + def test_request_sends_rfc8693_token_exchange_parameters + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "id-jag-assertion", + issued_token_type: "urn:ietf:params:oauth:token-type:id-jag", + token_type: "N_A", + ), + ) + + assertion = request_exchange + + assert_equal("id-jag-assertion", assertion) + assert_requested(:post, @token_endpoint) do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" && + form["subject_token"] == "idp-id-token" && + form["subject_token_type"] == "urn:ietf:params:oauth:token-type:id_token" && + form["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" && + form["audience"] == "https://auth.example.com" && + form["resource"] == "https://srv.example.com/mcp" && + form["client_id"] == "idp-client" + end + end + + def test_request_rejects_response_without_id_jag_token_type + # A plain access token is not an ID-JAG; presenting it as a + # jwt-bearer assertion would fail confusingly downstream. + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "ordinary-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + ), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/did not issue an ID-JAG/, error.message) + end + + def test_request_rejects_missing_access_token + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(issued_token_type: "urn:ietf:params:oauth:token-type:id-jag"), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/missing `access_token`/, error.message) + end + + def test_request_raises_on_non_2xx_response + stub_request(:post, @token_endpoint).to_return(status: 400, body: JSON.generate(error: "invalid_grant")) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/status 400/, error.message) + end + + def test_request_raises_on_unparseable_response + stub_request(:post, @token_endpoint).to_return(status: 200, body: "not json") + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/Failed to parse/, error.message) + end + + def test_request_raises_on_non_object_json_response + stub_request(:post, @token_endpoint).to_return(status: 200, body: "[]") + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/not a JSON object/, error.message) + end + end + end + end +end From 6236ea1b34964bb18d9defdefefdde354d96756a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 10 Jul 2026 01:01:15 +0900 Subject: [PATCH 150/247] Reject notifications carrying an unknown or expired session ## Motivation and Context In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject a message whose session no longer exists (the response branch via `session_exists?`, the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally. A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id` therefore passed the `missing_session_id` gate and the ownership gate (which returns true when the session does not exist) and reached `dispatch_notification` with no session, running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification handlers this had no visible effect, but a custom notification handler would then run without a live session behind it. `handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches. `session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime, so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that a notification with an unknown session ID is rejected with `404 Session not found` and that a notification carrying a live session ID is still accepted with `202`. The new rejection test fails against the previous code (it returned `202`) and passes now. ## Breaking Changes None for a conforming client: a client that has completed `initialize` sends its notifications with the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID, previously accepted with `202`, now receives `404 Session not found`. --- .../transports/streamable_http_transport.rb | 5 +++ .../streamable_http_transport_test.rb | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index fd45b33..bd39569 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -517,6 +517,11 @@ module MCP end if notification?(body) + # Reject a notification carrying an unknown or expired session ID instead of + # dispatching it against the shared `Server`. Mirrors the response and regular-request + # branches; without it a custom notification handler could run without a live session. + return session_not_found_response if !@stateless && !session_active?(session_id) + dispatch_notification(body_string, session_id) handle_accepted elsif response?(body) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index d36e1b0..3b79ce8 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -525,6 +525,40 @@ module MCP assert_equal "Missing session ID", body["error"]["message"] end + test "rejects notification with an unknown session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "does-not-exist" }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + + test "accepts a notification carrying a live session ID in stateful mode" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = @transport.handle_request(init_request)[1]["Mcp-Session-Id"] + + notification = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(notification) + assert_equal 202, response[0] + end + test "rejects response without session ID in stateful mode" do request = create_rack_request( "POST", From fc0c6b8f3fd8ff6e97d0a5b5c4acf2c155afb8dc Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 10 Jul 2026 01:08:10 +0900 Subject: [PATCH 151/247] Handle an initialize request sent without an ID ## Motivation and Context `StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`, which matches on the method name alone. An `initialize` message sent without an id (framed as a notification) therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized, but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil. Two things then went wrong: - `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response, so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`) and the client gets a malformed response or a 500. - The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized. The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions toward the `max_sessions` cap until the reaper reclaims them. `handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session and acks with 202, mirroring how `handle_regular_request` already handles a nil response. A well-formed `initialize` request (with an id) is unaffected. ## How Has This Been Tested? A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize` with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map. It fails against the previous code (a 200 with a nil body element and a retained session) and passes now. ## Breaking Changes None. A conforming client sends `initialize` as a request with an id and is unaffected; only the malformed id-less form changes, from a broken 200 to a 202 with no retained session. --- .../transports/streamable_http_transport.rb | 11 +++++++++++ .../streamable_http_transport_test.rb | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index fd45b33..7d919f3 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -844,6 +844,17 @@ module MCP response = server_session.handle_json(body_string) + # `initialize_request?` matches on the method alone, so an `initialize` sent without + # an id (framed as a notification) reaches here. `Server#init` marks the session initialized, + # but JSON-RPC emits no response for an id-less request, so `response` is nil. + # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize), + # and the session would be retained since it is marked initialized. Discard the orphaned session + # and ack with 202, mirroring the nil-response handling in a regular request. + if response.nil? + cleanup_session(session_id) if session_id + return handle_accepted + end + # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope), # `mark_initialized!` was never called. Discard the orphaned session and omit # the `Mcp-Session-Id` header so the client retries from a clean state instead of diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index d36e1b0..f8bfe1c 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -315,6 +315,24 @@ module MCP assert_equal({}, @transport.instance_variable_get(:@sessions)) end + test "acks an initialize sent without an id and retains no session" do + # `initialize` framed as a notification (no id) produces no JSON-RPC response. + # The transport must not put nil in the Rack body or retain the orphaned session. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize" }.to_json, + ) + + response = @transport.handle_request(request) + + assert_equal 202, response[0] + refute response[1].key?("Mcp-Session-Id"), "no session id should leak from an id-less init" + assert_equal [], response[2] + assert_equal({}, @transport.instance_variable_get(:@sessions)) + end + test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do request = create_rack_request( "POST", From ab4075c8c744042afc66d1017eff450b240f4285 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 15 Jul 2026 01:38:51 +0900 Subject: [PATCH 152/247] Fix RuboCop offenses detected with rubocop-shopify 3.0.0 ## Motivation and Context This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems. Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`, and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense from the new `Style/ReduceToHash` cop. This change fixes them as follows: - Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7, where Regexp literals are not frozen. - Remove inline disable directives that became redundant. - Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names can reference class names such as `NoMethodError`. - Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test since Mocha cannot stub methods on frozen objects. - Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`. Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0 ## How Has This Been Tested? `bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses. The conformance suite also passes against its baseline. ## Breaking Changes None. Freezing these constants only affects code that mutates them in place, which has never been supported. --- .rubocop.yml | 4 ++++ lib/json_rpc_handler.rb | 2 +- lib/mcp/client/oauth/discovery.rb | 2 +- lib/mcp/configuration.rb | 2 +- lib/mcp/icon.rb | 2 +- lib/mcp/instrumentation.rb | 2 -- lib/mcp/resource_template.rb | 2 +- lib/mcp/server.rb | 4 +--- .../transports/stdio_notification_integration_test.rb | 2 +- .../server/transports/streamable_http_transport_test.rb | 8 +++++++- 10 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 0a9cd89..f0c764e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,3 +16,7 @@ Lint/IncompatibleIoSelectWithFiberScheduler: Minitest/LiteralAsActualArgument: Enabled: true + +Naming/MethodName: + AllowedPatterns: + - '\Atest_' diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 9992ae7..0b008fa 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -16,7 +16,7 @@ module JsonRpcHandler PARSE_ERROR = -32700 end - DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/ + DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/.freeze # Sentinel return value from a handler. When a handler returns this, # `process_request` emits no JSON-RPC response for the request, diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index d1540b9..885f1db 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -37,7 +37,7 @@ module MCP # Matches a single `key=value` pair inside an HTTP auth-scheme challenge. # `value` is either a quoted string (which can contain commas and spaces) # or a bare token, per RFC 7235. - WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/ + WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze class << self # Parses a `WWW-Authenticate` header and returns the parameters of diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 70acb50..3603e6f 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -5,7 +5,7 @@ module MCP LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25" SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", - ] + ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" attr_writer :exception_reporter, :around_request diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb index 5758c42..3397c33 100644 --- a/lib/mcp/icon.rb +++ b/lib/mcp/icon.rb @@ -2,7 +2,7 @@ module MCP class Icon - SUPPORTED_THEMES = ["light", "dark"] + SUPPORTED_THEMES = ["light", "dark"].freeze attr_reader :mime_type, :sizes, :src, :theme diff --git a/lib/mcp/instrumentation.rb b/lib/mcp/instrumentation.rb index 97c431f..9a1f434 100644 --- a/lib/mcp/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -14,9 +14,7 @@ module MCP rescue => e already_reported = begin !!exception_already_reported&.call(e) - # rubocop:disable Lint/RescueException rescue Exception - # rubocop:enable Lint/RescueException # The predicate is expected to be side-effect-free and return a boolean. # Any exception raised from it (including non-StandardError such as SystemExit) # must not shadow the original exception. diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index 4edbc5d..71db231 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -8,7 +8,7 @@ module MCP # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`. # Variable names are restricted to valid Regexp named-group names, # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match. - VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/ + VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/.freeze attr_reader :uri_template_value attr_reader :title_value diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 2652c1d..0f77af3 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -963,9 +963,7 @@ module MCP end def index_resources_by_uri(resources) - resources.each_with_object({}) do |resource, hash| - hash[resource.uri] = resource - end + resources.to_h { |resource| [resource.uri, resource] } end def error_tool_response(text) diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb index ec67eaf..f0d1963 100644 --- a/test/mcp/server/transports/stdio_notification_integration_test.rb +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -27,7 +27,7 @@ module MCP nil # Simulate end of input end - def set_encoding(encoding) # rubocop:disable Naming/AccessorMethodName + def set_encoding(encoding) # Mock implementation end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index d36e1b0..6c4ad79 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1925,7 +1925,10 @@ module MCP end test "missing MCP-Protocol-Version header falls back to default for validation" do - MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.stubs(:include?).returns(false) + # The constant is frozen, so swap it out instead of stubbing `include?` on it. + original_versions = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS + MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) + MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, [].freeze) request = Rack::Request.new( "REQUEST_METHOD" => "POST", @@ -1938,6 +1941,9 @@ module MCP body = JSON.parse(response[2][0]) assert_includes body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION + ensure + MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) + MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions) end test "POST request with empty MCP-Protocol-Version header returns 400" do From f3d6da23710a2e15b23addca52e2edc4865379cc Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 15 Jul 2026 01:04:38 +0900 Subject: [PATCH 153/247] Support client-side sampling via `MCP::Client#on_sampling` ## Motivation and Context The MCP specification defines `sampling/createMessage` as a server-to-client request that lets a server ask the client to generate an LLM completion, so the server can leverage the client's model access without its own API keys. The Ruby SDK already supports the server side (sending the request) and, on the client side, the generic server-to-client request infrastructure introduced for elicitation (the standalone GET SSE listening stream and `on_server_request` dispatch). This adds the client-side `sampling/createMessage` handler on top of that infrastructure. ## Changes - Add `MCP::Client#on_sampling`, a thin wrapper that registers a `sampling/createMessage` handler via `transport.on_server_request`, mirroring `on_elicitation`. The handler receives the request params and returns a `CreateMessageResult`-shaped Hash sent back as the JSON-RPC result. - Document client-side sampling in the README, including the `sampling` (and `sampling.tools`) capability declaration and the human-in-the-loop guidance from the specification. ## How Has This Been Tested? - Unit tests for `on_sampling` covering handler registration on the transport and the error raised when the transport does not support server-to-client requests. - An HTTP transport test that dispatches a `sampling/createMessage` server request delivered on the SSE stream and returns a `CreateMessageResult`. ## Deprecation Note SEP-2577 deprecates sampling (along with roots and logging) starting with protocol version `2026-07-28`. The deprecation warnings are added in https://github.com/modelcontextprotocol/ruby-sdk/pull/406. Sampling remains fully supported under the current `2025-11-25` protocol version and stays available throughout the spec's deprecation window, so the client side is implemented here for parity with the server side and with the Python and TypeScript SDKs. `MCP::Client#on_sampling` carries the same `@deprecated` annotation as the server-side senders annotated in #429, tailored to the receiving side: the handler exists to interoperate with servers that still send sampling requests during the deprecation window. The README section carries the same note. Ref: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling --- README.md | 38 ++++++++++++++++++++++ lib/mcp/client.rb | 38 ++++++++++++++++++++++ test/mcp/client/http_test.rb | 61 ++++++++++++++++++++++++++++++++++++ test/mcp/client_test.rb | 19 +++++++++++ 4 files changed, 156 insertions(+) diff --git a/README.md b/README.md index 390dd8f..4d7e85e 100644 --- a/README.md +++ b/README.md @@ -2342,6 +2342,44 @@ since servers deliver requests that are not tied to a client request on that str a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with `http_transport.on_server_request("method/name") { |params| ... }`. +#### Server-to-Client Requests (Sampling) + +Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), +letting a server leverage the client's model access without its own API keys. + +> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`. +> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; +> new servers should call LLM provider APIs directly. + +Register a handler and advertise the capability on `connect`: + +```ruby +client.connect(capabilities: { sampling: {} }) + +client.on_sampling do |params| + completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + { + role: "assistant", + content: { type: "text", text: completion.text }, + model: completion.model, + stopReason: "endTurn", + } +end +``` + +For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. +To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: + +```ruby +client.on_sampling do |params| + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + + generate_completion(params) +end +``` + +Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. + #### HTTP Authorization By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index f6522ec..9d139c6 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -423,6 +423,44 @@ module MCP transport.on_server_request(Methods::ELICITATION_CREATE, &handler) end + # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight. + # The handler receives the request `params` (`messages`, `maxTokens`, optionally `systemPrompt`, `modelPreferences`, `tools`, + # `toolChoice`, ...; string keys) and must return a `CreateMessageResult`-shaped Hash: + # `{ role: "assistant", content: { type: "text", text: "..." }, model: "...", stopReason: "..." }`. + # + # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response + # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`. + # + # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to + # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them. + # + # @example Forward the request to an LLM and return its completion + # + # client.on_sampling do |params| + # raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + # + # completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + # { + # role: "assistant", + # content: { type: "text", text: completion.text }, + # model: completion.model, + # stopReason: "endTurn", + # } + # end + # + # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling + # + # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window; + # new servers should call LLM provider APIs directly. + def on_sampling(&handler) + unless transport.respond_to?(:on_server_request) + raise ArgumentError, "The transport does not support server-to-client requests" + end + + transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler) + end + # Sends a `ping` request to the server to verify the connection is alive. # Per the MCP spec, the server responds with an empty result. # diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index a0a1fce..a288da7 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -913,6 +913,67 @@ module MCP assert_equal("Please accept with defaults", received_params["message"]) end + def test_send_request_dispatches_sampling_request_to_registered_handler + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "ask_llm", arguments: {} }, + } + + sampling_request = { + jsonrpc: "2.0", + id: 0, + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Hi" } }], + maxTokens: 100, + }, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{sampling_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { + jsonrpc: "2.0", + id: 0, + result: { + role: "assistant", + content: { type: "text", text: "Hello there" }, + model: "test-model", + stopReason: "endTurn", + }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return(status: 202, body: "") + + received_params = nil + client.on_server_request("sampling/createMessage") do |params| + received_params = params + { + role: "assistant", + content: { type: "text", text: "Hello there" }, + model: "test-model", + stopReason: "endTurn", + } + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + assert_equal(100, received_params["maxTokens"]) + end + def test_send_request_answers_unregistered_server_request_with_method_not_found request = { jsonrpc: "2.0", diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 390def3..3f7d180 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -66,6 +66,25 @@ module MCP assert_includes(error.message, "does not support server-to-client requests") end + def test_on_sampling_registers_handler_on_transport + transport = mock + handler = proc { { role: "assistant", content: { type: "text", text: "hi" } } } + transport.expects(:on_server_request).with("sampling/createMessage") + + Client.new(transport: transport).on_sampling(&handler) + end + + def test_on_sampling_raises_when_transport_does_not_support_server_requests + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + + error = assert_raises(ArgumentError) do + Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } + end + + assert_includes(error.message, "does not support server-to-client requests") + end + def test_connected_delegates_to_transport_when_supported transport = mock transport.expects(:connected?).returns(true) From ced5cd1a950c70078b52e76fd41f4d6ea5a60b1f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 14 Jul 2026 00:52:50 +0900 Subject: [PATCH 154/247] Bound client-side message buffering in the HTTP transport ## Motivation and Context `MCP::Client::HTTP` buffers server bytes with no upper bound in two places: - SSE responses are parsed incrementally, but the parser accumulates `data:` lines until a blank line dispatches the event, so a server that never terminates an event grows the buffer indefinitely. The abort-on-response signal does not help here, since it only fires after a complete response event has been dispatched. - Non-SSE (JSON) bodies accumulate in `SSEStream#buffer` chunk by chunk with no limit. Both the POST response stream and the standalone GET listening stream are affected. The stdio client already bounds server frames (`max_line_bytes:`, 4 MiB default) and the server transports bound request bodies (`max_request_bytes:`, 4 MiB default); this brings the HTTP client in line. `MCP::Client::HTTP.new` gains `max_message_bytes:` (default 4 MiB), which caps the bytes buffered for a single message - an SSE event or a JSON response body. External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones, so the check measures the parser's own String buffers after each chunk. An over-limit message raises `RequestHandlerError` on the request path and stops the GET listening stream instead of reconnecting to the same over-limit event. ## How Has This Been Tested? New tests in `test/mcp/client/http_test.rb` cover the SSE and JSON rejection paths end-to-end, the per-event reset (events that individually fit must not count against later ones), the env-less `on_data` fallback, and the constructor validation. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them. --- README.md | 4 ++ lib/mcp/client/http.rb | 62 +++++++++++++++++++- test/mcp/client/http_test.rb | 107 +++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 390dd8f..71cbf75 100644 --- a/README.md +++ b/README.md @@ -2318,6 +2318,10 @@ response = client.call_tool( The server will send `notifications/progress` back to the client during execution. +`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server - +an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from +a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses. + #### Server-to-Client Requests (Elicitation) Servers can send requests back to the client while one of the client's own requests is in flight - for example, diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index eb6ca6e..112fe3e 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -30,6 +30,13 @@ module MCP # (60 seconds for Net::HTTP) would recycle quiet streams too eagerly. SSE_LISTENER_READ_TIMEOUT = 300 + # Upper bound in bytes on a single JSON-RPC message from the server - an SSE event or + # a JSON response body - buffered in memory while reading a response. Without a bound, + # a server that never terminates an SSE event (or never ends a JSON body) grows + # the buffer indefinitely. Matches the 4 MiB default of `MCP::Client::Stdio::MAX_LINE_BYTES` + # and the server transports' request cap. + MAX_MESSAGE_BYTES = 4 * 1024 * 1024 + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host # is trivially observed and stolen. @@ -71,6 +78,12 @@ module MCP class StreamAbort < StandardError; end private_constant :StreamAbort + # Raised inside the streaming callback when the server sends more bytes than + # `max_message_bytes` without completing a message. Translated into + # a `RequestHandlerError` (or a listener stop) where the request context is known. + class MessageTooLargeError < StandardError; end + private_constant :MessageTooLargeError + # Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams: # the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`), # and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path. @@ -78,8 +91,9 @@ module MCP attr_reader :buffer, :response, :last_event_id, :retry_ms attr_accessor :abortable - def initialize(abortable:, on_request: nil) + def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil) @abortable = abortable + @max_message_bytes = max_message_bytes @on_request = on_request @buffer = +"" @parser = nil @@ -95,14 +109,24 @@ module MCP end # Faraday `on_data` streaming callback. SSE chunks are parsed incrementally; - # anything else (JSON bodies) accumulates in `buffer`. + # anything else (JSON bodies) accumulates in `buffer`. Both paths are bounded + # by `max_message_bytes`, checked after the awaited response had its chance to + # abort the stream so an over-limit tail cannot mask a response that already arrived. def on_data proc do |chunk, _received_bytes, env| if event_stream?(env) feed(chunk) raise StreamAbort if @abortable && @response + + if parser_buffered_bytes > @max_message_bytes + raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing" + end else @buffer << chunk + + if @buffer.bytesize > @max_message_bytes + raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes" + end end end end @@ -154,6 +178,20 @@ module MCP end end + # Bytes the parser has buffered for a not-yet-dispatched event: the partial line + # and the accumulated `data:` field values (plus the small event type and last event id buffers). + # External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters) + # from consumed-and-retained ones, so the parser's own String buffers are measured instead; + # summing every String instance variable keeps the measurement valid if the parser renames or adds buffers. + # A parser rewrite that buffered elsewhere would make this return 0 and quietly lift the cap - + # the regression tests feeding an over-limit event guard against that. + def parser_buffered_bytes + parser.instance_variables.sum do |name| + value = parser.instance_variable_get(name) + value.is_a?(String) ? value.bytesize : 0 + end + end + def parser @parser ||= begin require "event_stream_parser" @@ -169,7 +207,13 @@ module MCP attr_reader :url, :session_id, :protocol_version, :server_info, :oauth - def initialize(url:, headers: {}, oauth: nil, &block) + def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block) + # `nil` or a non-positive value would make the buffering unbounded and silently + # disable the protection, so reject it up front. + unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0 + raise ArgumentError, "max_message_bytes must be a positive Integer" + end + if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) # Mask credentials (userinfo) and query parameters before quoting the URL in the error message # so they cannot leak into logs. @@ -183,6 +227,7 @@ module MCP @headers = headers @faraday_customizer = block @oauth = oauth + @max_message_bytes = max_message_bytes # Snapshot the canonical URL at construction time. This single value # serves two related roles, both of which need to see the query string: # @@ -336,6 +381,7 @@ module MCP # so the response object (and its `Mcp-Session-Id` header) is always available for capture. stream = SSEStream.new( abortable: method.to_s != MCP::Methods::INITIALIZE, + max_message_bytes: @max_message_bytes, on_request: ->(message) { dispatch_server_request(message) }, ) @@ -354,6 +400,12 @@ module MCP capture_session_info(method, response, body) if response body + rescue MessageTooLargeError => e + raise RequestHandlerError.new( + e.message, + { method: method, params: params }, + error_type: :internal_error, + ) rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -699,6 +751,7 @@ module MCP def listen_for_server_requests stream = SSEStream.new( abortable: false, + max_message_bytes: @max_message_bytes, on_request: ->(message) { dispatch_server_request(message) }, ) consecutive_failures = 0 @@ -714,6 +767,9 @@ module MCP end consecutive_failures = 0 + rescue MessageTooLargeError + # Reconnecting would let the server stream the same over-limit event again, so stop listening instead of retrying. + break rescue Faraday::Error => e break if e.response&.dig(:status) == 405 diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index a0a1fce..471fa07 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -853,6 +853,113 @@ module MCP assert_empty(stream.buffer) end + def test_send_request_rejects_sse_event_exceeding_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 64) + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: #{"a" * 128}\n", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "Server SSE event exceeds 64 bytes") + assert_equal(:internal_error, error.error_type) + end + + def test_send_request_rejects_json_body_exceeding_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 64) + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: ["a" * 128] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "Server response body exceeds 64 bytes") + assert_equal(:internal_error, error.error_type) + end + + def test_send_request_accepts_sse_events_that_each_stay_within_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 256) + + # The notifications and the response together exceed the limit; each event stays + # under it, so dispatched events must not count against the ones that follow. + notification = { jsonrpc: "2.0", method: "notifications/progress", params: { token: "a" * 100 } }.to_json + sse_body = "data: #{notification}\n\n" * 3 + + "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{\"tools\":[]}}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal({ "tools" => [] }, response["result"]) + end + + def test_sse_stream_rejects_unterminated_event_exceeding_max_message_bytes + stream = HTTP.const_get(:SSEStream).new(abortable: false, max_message_bytes: 64) + env = stub(response_headers: { "content-type" => "text/event-stream" }) + chunk = "data: #{"a" * 128}" + + error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do + stream.on_data.call(chunk, chunk.bytesize, env) + end + + assert_includes(error.message, "Server SSE event exceeds 64 bytes") + end + + def test_sse_stream_bounds_buffered_chunks_when_env_is_unavailable + stream = HTTP.const_get(:SSEStream).new(abortable: true, max_message_bytes: 64) + + error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do + stream.on_data.call("a" * 65, 65) + end + + assert_includes(error.message, "Server response body exceeds 64 bytes") + end + + def test_initialize_rejects_max_message_bytes_that_is_not_a_positive_integer + [0, -1, nil, "4"].each do |value| + error = assert_raises(ArgumentError) do + HTTP.new(url: url, max_message_bytes: value) + end + + assert_equal("max_message_bytes must be a positive Integer", error.message) + end + end + def test_send_request_dispatches_server_request_to_registered_handler request = { jsonrpc: "2.0", From 991926a10d1cefaaa7a9a620a83e0da28e030a31 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 14:55:35 +0900 Subject: [PATCH 155/247] Lowercase response header names in `StreamableHTTPTransport` ## Motivation and Context The Rack 3 SPEC requires response header names to be lowercase. https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section: > Header keys must not contain uppercase ASCII characters (A-Z). https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`. Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance. HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected; only in-process consumers that read the raw Rack headers hash see the new keys. Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names a protocol error on HTTP/2, so proxies and h2-capable servers already require it. The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`, so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention, not a wire requirement. Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers, which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as an `Access-Control-Expose-Headers` value, which browsers match case-insensitively; and README/docs prose follows the MCP specification's documentation convention. Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys. AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing invites reintroducing capitalized names in future transport work. ## How Has This Been Tested? Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses. Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop, and rake conformance. ## Breaking Changes Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport, with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are unaffected because header names are case-insensitive on the wire. --- AGENTS.md | 1 + examples/streamable_http_server.rb | 2 +- .../transports/streamable_http_transport.rb | 18 +- ...able_http_notification_integration_test.rb | 12 +- .../streamable_http_transport_test.rb | 317 ++++++++++-------- 5 files changed, 196 insertions(+), 154 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b2208b..5f0ee62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Use frozen string literals - Follow Ruby community conventions - Keep dependencies minimal +- Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only ## Commit message conventions diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index 997b606..a20d28e 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -105,7 +105,7 @@ class McpSseLogger @mcp_logger.error("Response error: #{parsed["error"]["message"]}") else @mcp_logger.info("Response: success (id: #{parsed["id"]})") - @sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") if headers["Mcp-Session-Id"] + @sse_logger.info("Session created: #{headers["mcp-session-id"]}") if headers["mcp-session-id"] end rescue JSON::ParserError @mcp_logger.warn("Invalid JSON in response") diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4aacdb2..3c87dc9 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -19,9 +19,9 @@ module MCP class InvalidJsonError < StandardError; end SSE_HEADERS = { - "Content-Type" => "text/event-stream", - "Cache-Control" => "no-cache", - "Connection" => "keep-alive", + "content-type" => "text/event-stream", + "cache-control" => "no-cache", + "connection" => "keep-alive", }.freeze # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, @@ -566,7 +566,7 @@ module MCP end def handle_delete(request) - success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]] + success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]] if @stateless protocol_version_error = validate_protocol_version_header(request) @@ -762,7 +762,7 @@ module MCP def json_rpc_error_response(status:, code:, message:) body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } - [status, { "Content-Type" => "application/json" }, [body.to_json]] + [status, { "content-type" => "application/json" }, [body.to_json]] end def notification?(body) @@ -870,10 +870,10 @@ module MCP end headers = { - "Content-Type" => "application/json", + "content-type" => "application/json", } - headers["Mcp-Session-Id"] = session_id if session_id + headers["mcp-session-id"] = session_id if session_id [200, headers, [response]] end @@ -915,7 +915,7 @@ module MCP # which would produce an empty body the client cannot parse as JSON. return handle_accepted if response.nil? - [200, { "Content-Type" => "application/json" }, [response]] + [200, { "content-type" => "application/json" }, [response]] end end @@ -1148,7 +1148,7 @@ module MCP message: message, }, } - [400, { "Content-Type" => "application/json" }, [body.to_json]] + [400, { "content-type" => "application/json" }, [body.to_json]] end def session_already_connected_response diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 36bbca1..e1a37a5 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -28,7 +28,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -77,7 +77,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] init_request2 = create_rack_request( "POST", @@ -86,7 +86,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect both sessions with SSE io1 = StringIO.new @@ -132,7 +132,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -168,7 +168,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -212,7 +212,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 08405c4..099686e 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -57,7 +57,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Now make the ping request with the session ID request = create_rack_request( @@ -72,7 +72,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] io = StringIO.new response[2].call(io) @@ -82,6 +82,47 @@ module MCP assert_equal({}, body["result"]) end + test "response header names are lowercase as required by the Rack 3 spec" do + 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"] + + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + parse_error_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + "invalid json", + ) + + responses = { + initialize: init_response, + get_sse: @transport.handle_request(get_request), + delete: @transport.handle_request(delete_request), + parse_error: @transport.handle_request(parse_error_request), + } + + responses.each do |name, (_status, headers, _body)| + headers.each_key do |key| + assert_equal key.downcase, key, "#{name} response header `#{key}` must be lowercase" + end + end + end + test "id-bearing notification message is rejected with Method not found" do init_request = create_rack_request( "POST", @@ -90,7 +131,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -124,7 +165,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -141,7 +182,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -169,7 +210,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -199,8 +240,8 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "application/json", response[1]["Content-Type"] - assert response[1]["Mcp-Session-Id"] + assert_equal "application/json", response[1]["content-type"] + assert response[1]["mcp-session-id"] body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -216,7 +257,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] assert session_id duplicate_request = create_rack_request( @@ -272,7 +313,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] assert(session_id) sleep(0.1) @@ -308,7 +349,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - refute response[1].key?("Mcp-Session-Id"), "no session id should leak from a failed init" + refute response[1].key?("mcp-session-id"), "no session id should leak from a failed init" body = JSON.parse(response[2][0]) assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] @@ -359,7 +400,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then try to connect with GET request = create_rack_request( @@ -372,7 +413,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] assert response[2].is_a?(Proc) # The body should be a Proc for streaming end @@ -384,7 +425,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE then close it io = StringIO.new @@ -411,7 +452,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] end test "handles POST request as SSE even when GET SSE stream has EPIPE" do @@ -422,7 +463,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE with a broken pipe reader, writer = IO.pipe @@ -449,7 +490,7 @@ module MCP response = @transport.handle_request(request) assert_equal(200, response[0]) - assert_equal("text/event-stream", response[1]["Content-Type"]) + assert_equal("text/event-stream", response[1]["content-type"]) ensure begin writer.close @@ -466,7 +507,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE with a mock that raises ECONNRESET mock_stream = Object.new @@ -494,7 +535,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] end test "handles GET request with missing session ID" do @@ -506,7 +547,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -564,7 +605,7 @@ module MCP { "CONTENT_TYPE" => "application/json" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = @transport.handle_request(init_request)[1]["Mcp-Session-Id"] + session_id = @transport.handle_request(init_request)[1]["mcp-session-id"] notification = create_rack_request( "POST", @@ -649,7 +690,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Simulate an active SSE stream by storing a stream object in the session mock_stream = StringIO.new @@ -664,7 +705,7 @@ module MCP response = @transport.handle_request(get_request) assert_equal 409, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -682,7 +723,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Establish stream A stream_a = StringIO.new @@ -709,7 +750,7 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Session not found", body["error"]["message"] @@ -728,7 +769,7 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Session not found", body["error"]["message"] @@ -743,7 +784,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then try to delete it request = create_rack_request( @@ -754,7 +795,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert body["success"] @@ -769,7 +810,7 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Session not found", body["error"]["message"] @@ -783,7 +824,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] delete_request = create_rack_request( "DELETE", @@ -816,7 +857,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] first_delete = create_rack_request( "DELETE", @@ -847,7 +888,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Missing session ID", body["error"]["message"] @@ -862,7 +903,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then connect with GET io = StringIO.new @@ -898,7 +939,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Simulate multiple request_streams being set on the session. closed = [] @@ -947,7 +988,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] # Create second session init_request2 = create_rack_request( @@ -957,7 +998,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect first session with GET io1 = StringIO.new @@ -1050,7 +1091,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -1086,7 +1127,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] init_request2 = create_rack_request( "POST", @@ -1095,7 +1136,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect both sessions with SSE io1 = StringIO.new @@ -1150,7 +1191,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -1187,7 +1228,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Use a mock stream that raises Errno::ECONNRESET on write. mock_stream = Object.new @@ -1224,7 +1265,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Use a mock stream that verifies mutex is NOT held during close. mutex = @transport.instance_variable_get(:@mutex) @@ -1255,7 +1296,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. io = StringIO.new @@ -1294,7 +1335,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. io = StringIO.new @@ -1335,7 +1376,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] init_request2 = create_rack_request( "POST", @@ -1344,7 +1385,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "2", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Session 1: mock stream that raises ECONNRESET. broken_stream = Object.new @@ -1454,7 +1495,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Use a mock stream that raises Errno::ECONNRESET on write. mock_stream = Object.new @@ -1503,7 +1544,7 @@ module MCP response = @transport.handle_request(request) assert_equal 405, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -1570,7 +1611,7 @@ module MCP response = @transport.handle_request(request) assert_equal 406, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", @@ -1683,7 +1724,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request_without_accept( "GET", @@ -1696,7 +1737,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] end test "GET request without Accept header returns 406" do @@ -1707,7 +1748,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request_without_accept( "GET", @@ -1733,7 +1774,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request_without_accept( "GET", @@ -1759,7 +1800,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "GET", @@ -1772,7 +1813,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] end test "GET request with Accept: */* succeeds" do @@ -1783,7 +1824,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request_without_accept( "GET", @@ -1796,7 +1837,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] end test "POST initialize request ignores MCP-Protocol-Version header" do @@ -1874,7 +1915,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -1889,7 +1930,7 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -1907,7 +1948,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -1935,7 +1976,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -1960,7 +2001,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -2006,7 +2047,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -2034,7 +2075,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -2062,7 +2103,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "GET", @@ -2088,7 +2129,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "GET", @@ -2108,7 +2149,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "DELETE", @@ -2163,7 +2204,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = stateless_transport.handle_request(init_request) - assert_nil init_response[1]["Mcp-Session-Id"] + assert_nil init_response[1]["mcp-session-id"] end test "stateless mode responds without any session ID when session ID is present" do @@ -2183,7 +2224,7 @@ module MCP assert_equal 200, response[0] assert_equal( { - "Content-Type" => "application/json", + "content-type" => "application/json", }, response[1], ) @@ -2205,7 +2246,7 @@ module MCP ) response = stateless_transport.handle_request(get_request) assert_equal 405, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "Method not allowed", body["error"]["message"] @@ -2221,7 +2262,7 @@ module MCP ) response = stateless_transport.handle_request(delete_request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert body["success"] @@ -2237,7 +2278,7 @@ module MCP ) response = stateless_transport.handle_request(delete_request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert body["success"] @@ -2304,7 +2345,7 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) assert body.key?("result"), "initialize ##{i + 1} should succeed, got #{body.inspect}" - refute response[1].key?("Mcp-Session-Id") + refute response[1].key?("mcp-session-id") end end @@ -2365,7 +2406,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with GET SSE io = StringIO.new @@ -2391,7 +2432,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] post_io = StringIO.new response[2].call(post_io) @@ -2411,7 +2452,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -2425,7 +2466,7 @@ module MCP response = transport.handle_request(request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) + assert_equal("application/json", response[1]["content-type"]) body = JSON.parse(response[2][0]) assert_equal("1", body["id"]) @@ -2517,7 +2558,7 @@ module MCP }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] tool_request = create_rack_request( "POST", @@ -2536,7 +2577,7 @@ module MCP response = transport.handle_request(tool_request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) + assert_equal("application/json", response[1]["content-type"]) body = JSON.parse(response[2][0]) assert_equal("call-1", body["id"]) @@ -2569,7 +2610,7 @@ module MCP }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. io = StringIO.new @@ -2600,7 +2641,7 @@ module MCP response = transport.handle_request(tool_request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) + assert_equal("application/json", response[1]["content-type"]) # Notification should NOT leak to GET SSE stream. io.rewind @@ -2678,7 +2719,7 @@ module MCP }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. io = StringIO.new @@ -2709,7 +2750,7 @@ module MCP response = transport.handle_request(tool_request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) + assert_equal("application/json", response[1]["content-type"]) # Broadcast notification should arrive on GET SSE stream. io.rewind @@ -2732,8 +2773,8 @@ module MCP response = transport.handle_request(request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) - assert_nil(response[1]["Mcp-Session-Id"]) + assert_equal("application/json", response[1]["content-type"]) + assert_nil(response[1]["mcp-session-id"]) body = JSON.parse(response[2][0]) assert_equal("1", body["id"]) @@ -2777,7 +2818,7 @@ module MCP }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] io = StringIO.new get_request = create_rack_request( @@ -2806,7 +2847,7 @@ module MCP response = transport.handle_request(tool_request) assert_equal(200, response[0]) - assert_equal("application/json", response[1]["Content-Type"]) + assert_equal("application/json", response[1]["content-type"]) io.rewind assert_includes(io.read, "notifications/resources/updated") @@ -2828,7 +2869,7 @@ module MCP response = @transport.handle_request(request) assert_equal 500, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -2872,7 +2913,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] error = assert_raises(RuntimeError) do @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: session_id) @@ -2890,7 +2931,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect SSE. io = StringIO.new @@ -2959,7 +3000,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. get_io = StringIO.new @@ -3019,7 +3060,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init-a", params: initialize_params }.to_json, ) resp_a = @transport.handle_request(init_a) - session_a = resp_a[1]["Mcp-Session-Id"] + session_a = resp_a[1]["mcp-session-id"] init_b = create_rack_request( "POST", @@ -3028,7 +3069,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init-b", params: initialize_params }.to_json, ) resp_b = @transport.handle_request(init_b) - session_b = resp_b[1]["Mcp-Session-Id"] + session_b = resp_b[1]["mcp-session-id"] # Connect SSE for session A. io_a = StringIO.new @@ -3090,7 +3131,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] io = StringIO.new get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) @@ -3130,7 +3171,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] io = StringIO.new get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) @@ -3198,7 +3239,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] io = StringIO.new get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) @@ -3267,7 +3308,7 @@ module MCP }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] server_session = transport.instance_variable_get(:@sessions)[session_id][:server_session] tool_request = create_rack_request( @@ -3324,7 +3365,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect SSE. io = StringIO.new @@ -3383,7 +3424,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect SSE. io = StringIO.new @@ -3427,7 +3468,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Connect GET SSE. get_io = StringIO.new @@ -3526,7 +3567,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Set up sampling capability for the session. @transport.instance_variable_get(:@sessions)[session_id][:server_session] @@ -3612,7 +3653,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Define a tool that sends a notification during execution. notification_sent = Queue.new @@ -3669,7 +3710,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Define a tool that reports progress during execution. progress_reported = Queue.new @@ -3722,7 +3763,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] notif_request = create_rack_request( "POST", @@ -3751,7 +3792,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] assert(session_id) # Session should now be expired (timeout is 0) @@ -3782,7 +3823,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Session should now be expired (timeout is 0) sleep(0.01) @@ -3816,7 +3857,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Make a request - session should still be valid request = create_rack_request( @@ -3845,7 +3886,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -3873,7 +3914,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Send requests every 0.2s to keep the session alive. # Total elapsed time (~0.6s) exceeds timeout (0.5s), but each request @@ -3906,7 +3947,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] assert(session_id) # Wait for session to expire @@ -3937,7 +3978,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Wait for the session to exceed the idle timeout (0.01s) sleep(0.02) @@ -3972,7 +4013,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Replace the stream with one that verifies mutex is NOT held during close. mutex = transport.instance_variable_get(:@mutex) @@ -4033,7 +4074,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) # A freshly created session is well within the timeout, so normal use is unaffected. - session_id = transport.handle_request(init_request)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init_request)[1]["mcp-session-id"] request = create_rack_request( "POST", "/", @@ -4158,7 +4199,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Wait for the session to exceed the idle timeout (0.01s) sleep(0.02) @@ -4179,7 +4220,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] # Attach a mock stream to the session stream = StringIO.new @@ -4202,7 +4243,7 @@ module MCP { 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"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -4298,7 +4339,7 @@ module MCP params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) - session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + session1 = transport.handle_request(init1)[1]["mcp-session-id"] init2 = create_rack_request( "POST", @@ -4311,7 +4352,7 @@ module MCP params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) - session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + session2 = transport.handle_request(init2)[1]["mcp-session-id"] # Connect SSE for both sessions. io1 = StringIO.new @@ -4374,7 +4415,7 @@ module MCP params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) - session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + session1 = transport.handle_request(init1)[1]["mcp-session-id"] init2 = create_rack_request( "POST", @@ -4387,7 +4428,7 @@ module MCP params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) - session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + session2 = transport.handle_request(init2)[1]["mcp-session-id"] # Connect SSE for both sessions. io1 = StringIO.new @@ -4448,7 +4489,7 @@ module MCP params: initialize_params(clientInfo: { name: "alpha", version: "1.0" }), }.to_json, ) - session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + session1 = transport.handle_request(init1)[1]["mcp-session-id"] # Initialize session 2 with client "beta". init2 = create_rack_request( @@ -4462,7 +4503,7 @@ module MCP params: initialize_params(clientInfo: { name: "beta", version: "2.0" }), }.to_json, ) - session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + session2 = transport.handle_request(init2)[1]["mcp-session-id"] # Each session should have its own client info. sessions = transport.instance_variable_get(:@sessions) @@ -4486,7 +4527,7 @@ module MCP params: initialize_params(clientInfo: { name: "a", version: "1.0" }), }.to_json, ) - session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + session1 = transport.handle_request(init1)[1]["mcp-session-id"] init2 = create_rack_request( "POST", @@ -4499,7 +4540,7 @@ module MCP params: initialize_params(clientInfo: { name: "b", version: "1.0" }), }.to_json, ) - session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + session2 = transport.handle_request(init2)[1]["mcp-session-id"] # Session 1 sets log level to "error". set_level1 = create_rack_request( @@ -4553,7 +4594,7 @@ module MCP response = @transport.call(env) assert_equal 200, response[0] - assert_equal "application/json", response[1]["Content-Type"] + assert_equal "application/json", response[1]["content-type"] body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] @@ -4580,7 +4621,7 @@ module MCP "HTTP_ACCEPT" => "application/json, text/event-stream", } init_response = @transport.call(init_env) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] get_env = { "REQUEST_METHOD" => "GET", @@ -4592,7 +4633,7 @@ module MCP response = @transport.call(get_env) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] assert response[2].is_a?(Proc) end @@ -4605,7 +4646,7 @@ module MCP "HTTP_ACCEPT" => "application/json, text/event-stream", } init_response = @transport.call(init_env) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] delete_env = { "REQUEST_METHOD" => "DELETE", @@ -4629,7 +4670,7 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init-frozen", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -4640,7 +4681,7 @@ module MCP status, headers, = @transport.handle_request(request) assert_equal 200, status - assert_equal "text/event-stream", headers["Content-Type"] + assert_equal "text/event-stream", headers["content-type"] refute headers.frozen?, "SSE response headers should not be frozen" end @@ -4726,7 +4767,7 @@ module MCP status, headers, body = transport.handle_request(request) assert_equal(403, status) - assert_equal("application/json", headers["Content-Type"]) + assert_equal("application/json", headers["content-type"]) parsed = JSON.parse(body.first) assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"]) @@ -4848,7 +4889,7 @@ module MCP status, headers, body = @transport.handle_request(request) assert_equal(403, status) - assert_equal("application/json", headers["Content-Type"]) + assert_equal("application/json", headers["content-type"]) parsed = JSON.parse(body.first) assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"]) @@ -5025,7 +5066,7 @@ module MCP { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init)[1]["mcp-session-id"] attacker = create_rack_request( "POST", @@ -5056,7 +5097,7 @@ module MCP { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init)[1]["mcp-session-id"] same_origin = create_rack_request( "POST", @@ -5093,7 +5134,7 @@ module MCP { "CONTENT_TYPE" => "application/json" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init)[1]["mcp-session-id"] ping = lambda do create_rack_request( @@ -5126,7 +5167,7 @@ module MCP { "CONTENT_TYPE" => "application/json" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init)[1]["mcp-session-id"] refute_nil(session_id) headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id } @@ -5168,7 +5209,7 @@ module MCP { "CONTENT_TYPE" => "application/json" }, { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) - session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + session_id = transport.handle_request(init)[1]["mcp-session-id"] transport.handle_request(create_rack_request( "POST", @@ -5210,7 +5251,7 @@ module MCP { "CONTENT_TYPE" => "application/json" }, { jsonrpc: "2.0", method: "initialize", id: id, params: initialize_params }.to_json, ) - @transport.handle_request(init_request)[1]["Mcp-Session-Id"] + @transport.handle_request(init_request)[1]["mcp-session-id"] end # Installs a stream that records, on every write, whether `@mutex` was held at that moment. From c78d4a2301fa5e700cab8e02db96a9c87c48bcfe Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 17 Jul 2026 03:16:41 +0900 Subject: [PATCH 156/247] Avoid cancelling in-progress conformance runs on main ## Motivation and Context When two PRs are merged to main in quick succession, the Conformance Tests run for the earlier merge commit is cancelled by the run for the later one. On push events, `github.ref` is always `refs/heads/main`, so every merge to main shares the same concurrency group and `cancel-in-progress: true` cancels all but the latest run. As a result, only the last merged PR gets a conformance result. This change appends `github.sha` to the concurrency group for push events so that each merge commit runs independently. For pull_request events the SHA part is empty, keeping the existing behavior where pushing new commits to a PR cancels its superseded runs. ## How Has This Been Tested? Validated the workflow YAML syntax locally. The concurrent behavior itself can only be observed on GitHub Actions, by confirming that both Conformance Tests runs complete when two pushes land on main within a short interval. ## Breaking Changes None. --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9e708ed..4e1f526 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: concurrency: - group: conformance-${{ github.ref }} + group: conformance-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }} cancel-in-progress: true permissions: From df578d79ca190dd4d1fcca1cb18370b449b79d77 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 18 Jul 2026 10:47:52 +0900 Subject: [PATCH 157/247] Release 0.25.0 New features and changes have landed, making it a good time to cut a release. Further feature proposals can be incorporated in future releases. --- CHANGELOG.md | 14 ++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0c32c..bdb2b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-07-18 + +### Added + +- Support SEP-990 Cross-App Access via ID-JAG and the `jwt-bearer` grant (#454) +- Support client-side sampling via `MCP::Client#on_sampling` (#458) + +### Fixed + +- Reject notifications carrying an unknown or expired session (#455) +- Handle an `initialize` request sent without an ID (#456) +- Bound client-side message buffering in the HTTP transport (#459) +- Lowercase response header names in `StreamableHTTPTransport` (#460) + ## [0.24.0] - 2026-07-12 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 57b753e..9594104 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.24.0" + VERSION = "0.25.0" end From f3ffe43cdb9a687f913fdde768068ae9e19566f0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 03:42:37 +0900 Subject: [PATCH 158/247] [Conformance] Report expected client terminal failures quietly ## Motivation and Context Negative auth scenarios such as `auth/resource-mismatch` and `auth/scope-retry-limit` are designed to drive the client into a terminal failure: the SDK correctly refuses a mismatched protected-resource metadata `resource` and stops after the scope step-up retry limit. The conformance harness asserts on the observed protocol exchange and already marks these scenarios as passed, but the client script had no top-level rescue, so each run leaked a raw Ruby backtrace to stderr inside a noisy "Client exited with code 1" block. This change wraps the connect and scenario dispatch in a rescue that reports the expected `AuthorizationError`, `RequestHandlerError`, and `Faraday::Error` outcomes as a concise one-line reason on stderr and exits with status 0. The harness echoes the client's output only when the exit status is non-zero and does not consult the exit status for its verdict, so the expected failures no longer produce any output block at all; the one-line reason remains visible in manual runs and in the harness `--output-dir` stderr artifact. Any unexpected error still raises with a full backtrace and a failing exit status so genuine SDK bugs remain visible. ## How Has This Been Tested? Ran `bundle exec rake conformance`: the server suite passes 40/40 and the client suite passes 280/280 with no "Client exited with code" or "Stderr:" blocks in the output. Exercised `auth/resource-mismatch` and `auth/scope-retry-limit` individually via `Conformance::ClientRunner`; both pass with clean output. ## Breaking Changes None. Only the conformance client script output changes; no library code is affected. --- conformance/client.rb | 110 +++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/conformance/client.rb b/conformance/client.rb index 42a2ce1..c41a30b 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -163,55 +163,67 @@ oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {} -client.connect( - client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, - capabilities: capabilities, -) +# The conformance harness asserts on the observed protocol exchange, not on the client's exit status, +# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios +# (e.g. `auth/resource-mismatch`, `auth/scope-retry-limit`) are expected to drive the client into +# a terminal failure: the SDK correctly refuses a mismatched resource or stops after the scope step-up +# retry limit. Report such an expected failure as a one-line reason and exit cleanly so the run stays +# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace +# and a failing exit status. +begin + client.connect( + client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, + capabilities: capabilities, + ) -case scenario -when "initialize" - client.tools -when "tools_call" - tools = client.tools - add_numbers = tools.find { |t| t.name == "add_numbers" } - abort("Tool add_numbers not found") unless add_numbers - client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) -when "sse-retry" - # SEP-1699: the server closes the tools/call SSE stream right after a priming event. - # The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`, - # and receives the tool result on the resumed stream; the harness verifies the reconnect, - # its timing, and the header. - tools = client.tools - test_reconnection = tools.find { |t| t.name == "test_reconnection" } - abort("Tool test_reconnection not found") unless test_reconnection - client.call_tool(tool: test_reconnection, arguments: {}) -when "elicitation-sep1034-client-defaults" - # SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring - # a `default` and none required. The handler accepts and fills the omitted fields from those defaults. - client.on_elicitation do |params| - { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) } + case scenario + when "initialize" + client.tools + when "tools_call" + tools = client.tools + add_numbers = tools.find { |t| t.name == "add_numbers" } + abort("Tool add_numbers not found") unless add_numbers + client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) + when "sse-retry" + # SEP-1699: the server closes the tools/call SSE stream right after a priming event. + # The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`, + # and receives the tool result on the resumed stream; the harness verifies the reconnect, + # its timing, and the header. + tools = client.tools + test_reconnection = tools.find { |t| t.name == "test_reconnection" } + abort("Tool test_reconnection not found") unless test_reconnection + client.call_tool(tool: test_reconnection, arguments: {}) + when "elicitation-sep1034-client-defaults" + # SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring + # a `default` and none required. The handler accepts and fills the omitted fields from those defaults. + client.on_elicitation do |params| + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) } + end + + tools = client.tools + defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" } + + abort("Tool test_client_elicitation_defaults not found") unless defaults_tool + + client.call_tool(tool: defaults_tool, arguments: {}) + when %r|\Aauth/| + # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) + # are observed by the conformance server during `connect` and the subsequent request below. + # Listing tools forces a second authenticated MCP request so the bearer token usage check fires. + tools = client.tools + + # `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`, + # so the client must actually invoke a tool to drive the second authorization request + # the scenario asserts on. + if scenario == "auth/scope-step-up" + tool = tools.find { |t| t.name == "test-tool" } || tools.first + abort("No tool exposed by conformance server for #{scenario}") unless tool + client.call_tool(tool: tool, arguments: {}) + end + else + abort("Unknown or unsupported scenario: #{scenario}") end - - tools = client.tools - defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" } - - abort("Tool test_client_elicitation_defaults not found") unless defaults_tool - - client.call_tool(tool: defaults_tool, arguments: {}) -when %r|\Aauth/| - # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) - # are observed by the conformance server during `connect` and the subsequent request below. - # Listing tools forces a second authenticated MCP request so the bearer token usage check fires. - tools = client.tools - - # `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`, - # so the client must actually invoke a tool to drive the second authorization request - # the scenario asserts on. - if scenario == "auth/scope-step-up" - tool = tools.find { |t| t.name == "test-tool" } || tools.first - abort("No tool exposed by conformance server for #{scenario}") unless tool - client.call_tool(tool: tool, arguments: {}) - end -else - abort("Unknown or unsupported scenario: #{scenario}") +rescue MCP::Client::OAuth::Flow::AuthorizationError, MCP::Client::RequestHandlerError, Faraday::Error => e + warn("#{scenario}: client ended in the expected terminal failure (#{e.class}: #{e.message})") + exit end From 9ac6f577e09b35339060f290f0ca5c14397035cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:13:45 +0000 Subject: [PATCH 159/247] Bump actions/setup-node from 6 to 7 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4e1f526..dccb8af 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -23,7 +23,7 @@ jobs: with: ruby-version: '4.0' # Specify the latest supported Ruby version. bundler-cache: true - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' # Specify the latest Node.js version. - run: bundle exec rake conformance From 3fcef2dda3dcf043e35652ac9db7eaaed9020584 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 25 Jun 2026 03:14:19 +0900 Subject: [PATCH 160/247] Add ROADMAP.md outlining the path toward SEP-1730 Tier 1 ## Motivation and Context The SDK tiering system (SEP-1730) requires a published roadmap for both Tier 1 and Tier 2. Until now the Ruby SDK had no roadmap document, which left users without visibility into upcoming feature support and blocked the tiering requirement. This document records the SDK's current conformance status (all server and client scenarios passing), the API stability policy backed by Semantic Versioning and VERSIONING.md, the handling of features deprecated by the 2026-07-28 spec revision (SEP-2577), and how new MCP spec revisions will be tracked. Legacy SSE transport is explicitly called out as out of scope so users are not left guessing about it. ## How Has This Been Tested? Documentation-only change; there is no code to exercise. The conformance figures cited in the roadmap were taken from the current SEP-1730 tier assessment run against the latest main. ## Breaking Changes None. This adds a new top-level Markdown document and touches no code. --- ROADMAP.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6953b4a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,39 @@ +# Roadmap + +This roadmap outlines the MCP Ruby SDK's path toward SEP-1730 Tier 1. +It is a living document and will evolve as the SDK matures. + +## Current Status + +The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios. + +## API Stability + +The 1.0.0 release will mark the public API as stable: breaking changes then ship only in major releases, +apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md), +which also defines the Semantic Versioning scheme and breaking-change policy. + +## Deprecated Features + +The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577). +These features remain fully supported throughout 1.x, and deprecation warnings will be added in a future minor release. +Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: +whether 2.0.0 removes them depends on how future MCP spec revisions treat these features +and on adoption of their replacements. + +## Conformance + +The SDK maintains a 100% conformance pass rate as new scenarios are added to the conformance suite. +Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provides modern Streamable HTTP only. + +## Documentation + +Reference documentation covers all core features. + +## Tracking New Spec Revisions + +The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per +release based on feature complexity. +Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests, +and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision, +such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0. From 975019bb9cd4bc7a79ce2614ca60bdf65698023f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 23:16:08 +0900 Subject: [PATCH 161/247] Add VERSIONING.md documenting the versioning and breaking-change policy ## Motivation and Context The SDK tiering system (SEP-1730) requires Tier 1 SDKs to publish a clear versioning policy with a documented breaking-change policy. The CHANGELOG already states that the project adheres to Semantic Versioning, but there was no standalone document describing what counts as a breaking change, how the 0.x phase differs from post-1.0.0, or how incompatibilities are communicated to users. One policy decision is worth calling out: minor releases avoid incompatible changes as much as possible, but fixes for incorrect MCP spec conformance, for security vulnerabilities, and for clear defects (behavior contradicting the documentation, crashes, or data corruption) may ship in a minor release even when they are behavior-breaking. The SDK's primary contract is conformance to the MCP specification, so behavior that deviates from the spec is treated as a bug rather than an API guarantee, and preserving compatibility with a clear defect would mean preserving the defect itself. ## How Has This Been Tested? Documentation-only change; there is no code to exercise. The referenced files (CHANGELOG.md, GitHub Releases) and their formats were verified against the current repository state. ## Breaking Changes None. This adds a new top-level Markdown document and touches no code. --- VERSIONING.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 VERSIONING.md diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..1b2cd88 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,56 @@ +# Versioning Policy + +The MCP Ruby SDK follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +This document describes what each version component means for users of the `mcp` gem, +what counts as a breaking change, and how breaking changes are communicated. + +## Version Scheme + +Given a version number `MAJOR.MINOR.PATCH`: + +- **MAJOR** is incremented for incompatible changes to the public API. +- **MINOR** is incremented for backwards-compatible new functionality. +- **PATCH** is incremented for backwards-compatible bug fixes. + +### Current 0.x Phase + +While the SDK is at version 0.x, the public API is not yet considered stable. +Following SemVer conventions for initial development, minor releases (0.x -> 0.y) may contain breaking changes. +All breaking changes are recorded in [CHANGELOG.md](CHANGELOG.md). + +### After 1.0.0 + +Once 1.0.0 is released, breaking changes will only ship in major releases, with the exceptions described below. + +## What Counts as a Breaking Change + +A breaking change is any change that requires users to modify their code when upgrading, including: + +- Removing or renaming a public class, module, method, or constant +- Changing a public method's signature in a way that rejects previously valid arguments +- Changing documented behavior that users can reasonably depend on + +New functionality that is purely additive (new classes, new methods, new optional keyword arguments) +is not a breaking change. + +## Exceptions: Spec Compliance, Security, and Clear Defects + +Minor releases avoid incompatible changes as much as possible. However, the SDK's primary contract is +conformance to the [MCP specification](https://modelcontextprotocol.io/specification/). +Behavior that deviates from the specification is treated as a bug, even when users may have come to rely on it. +Therefore, incompatible changes may be shipped in a minor release when they are required to: + +- Fix incorrect conformance to the MCP specification +- Address a security vulnerability +- Fix a clear defect, such as behavior that contradicts the documentation, a crash, + or data corruption, where compatibility would mean preserving the defect + +When such a change ships, the release notes and CHANGELOG entry explicitly call out the incompatibility +and describe how to migrate. + +## How Breaking Changes Are Communicated + +- [CHANGELOG.md](CHANGELOG.md) records every release in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format; + breaking changes appear under the "Changed" or "Removed" headings +- [GitHub Releases](https://github.com/modelcontextprotocol/ruby-sdk/releases) mirror the changelog for each release +- Where practical, deprecation warnings are added at least one minor release before removal From 74fd99a86c718d1bb7f240e02a49c869cbbd53b0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 27 Jun 2026 14:46:36 +0900 Subject: [PATCH 162/247] Fix a `NoMethodError` on Ruby 2.7 caused by `Hash#except` ## Motivation and Context The gemspec already declares `required_ruby_version >= 2.7.0`, but the conformance Rake task used `Hash#except` to drop the `:port` option before handing the remaining options to `Conformance::ClientRunner`. `Hash#except` was only added in Ruby 3.0, so running `rake conformance` on Ruby 2.7 aborted before any scenario ran, raising `NoMethodError: undefined method except for {}:Hash`. CI missed this because no job runs `rake conformance` on Ruby 2.7: the conformance workflow runs only on Ruby 4.0, while the Ruby 2.7.0 matrix entry runs `rake test` alone. ## How Has This Been Tested? Replace `options.except(:port)` with `options.reject { |key, _| key == :port }`, which has been available since well before Ruby 2.7 and returns an equivalent Hash without mutating the original. `rake conformance` now runs the full suite on Ruby 2.7, and its baseline still passes on current Ruby, verified on Ruby 4.0. ## Breaking Changes None. --- Rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index 62e92d5..36da72c 100644 --- a/Rakefile +++ b/Rakefile @@ -30,7 +30,7 @@ task :conformance do |t| options[:verbose] = true if ENV["VERBOSE"] Conformance::ServerRunner.new(**options).run - Conformance::ClientRunner.new(**options.except(:port)).run + Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run end desc "List available conformance scenarios" From 43a0ba62662b73e9d8396e7cd610725bd4fc765b Mon Sep 17 00:00:00 2001 From: King Star Date: Thu, 23 Jul 2026 03:11:48 +0800 Subject: [PATCH 163/247] fix: preserve explicit tool response content --- lib/mcp/server.rb | 24 ++++++--- lib/mcp/tool/response.rb | 10 +++- test/mcp/server_test.rb | 89 ++++++++++++++++++++++++++++++---- test/mcp/tool/response_test.rb | 6 +++ 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 0f77af3..586d0de 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -745,11 +745,15 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - result = call_tool_with_args( + response = call_tool_with_args( tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation ) + result = response.to_h validate_tool_call_result!(tool, result) - serialize_structured_content_fallback(result) + serialize_structured_content_fallback( + result, + content_provided: response.respond_to?(:content_provided?) && response.content_provided?, + ) rescue RequestHandlerError, CancelledError # CancelledError is intentionally not wrapped so `handle_request` can turn it into # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. @@ -986,14 +990,18 @@ module MCP # Per SEP-2106, `structuredContent` may be any JSON value, not only an object. # Clients on older protocol versions may only read `content`, - # so when a tool returns non-object structured content without providing - # any content blocks, mirror the value into `content` as serialized JSON text. - def serialize_structured_content_fallback(result) + # so when a tool returns non-object structured content without explicitly + # providing `content`, mirror the value into serialized JSON text. + def serialize_structured_content_fallback(result, content_provided: false) structured = result[:structuredContent] return result if structured.nil? || structured.is_a?(Hash) + return result if content_provided return result unless result[:content].nil? || result[:content].empty? - result.merge(content: [{ type: "text", text: JSON.generate(structured) }]) + serialized = JSON.generate(structured) + return result if JSON.parse(serialized).is_a?(Hash) + + result.merge(content: [{ type: "text", text: serialized }]) end # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. @@ -1026,9 +1034,9 @@ module MCP related_request_id: related_request_id, cancellation: cancellation, ) - tool.call(**args, server_context: server_context).to_h + tool.call(**args, server_context: server_context) else - tool.call(**args).to_h + tool.call(**args) end end diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index 2532ea8..9e4b2ec 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -7,18 +7,24 @@ module MCP attr_reader :content, :structured_content, :meta - def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) + def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) if deprecated_error != NOT_GIVEN warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1) error = deprecated_error end - @content = content || [] + content_given = !content.equal?(NOT_GIVEN) + @content_provided = content_given && !content.nil? + @content = content_given ? (content || []) : [] @error = error @structured_content = structured_content @meta = meta end + def content_provided? + @content_provided + end + def error? !!@error end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 18240b0..7281a43 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3156,6 +3156,16 @@ module MCP end end + class JsonObjectValue + def initialize(value) + @value = value + end + + def to_json(*args) + @value.to_json(*args) + end + end + test "server_context_with_meta uses accessor method, not ivar directly" do subclass = Class.new(Server) do def server_context @@ -3247,23 +3257,28 @@ module MCP end end - test "#handle tools/call mirrors non-object structuredContent into serialized text content" do + test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, # so the server adds a serialized fallback when the tool provided no content blocks. server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "array_tool") do + server.define_tool(name: "nil_content_tool") do Tool::Response.new(nil, structured_content: [1, 2]) end + server.define_tool(name: "omitted_content_tool") do + Tool::Response.new(structured_content: [1, 2]) + end - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "array_tool", arguments: {} }, - }) + ["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index| + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: index + 1, + params: { name: tool_name, arguments: {} }, + }) - assert_equal [1, 2], response.dig(:result, :structuredContent) - assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + end end test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do @@ -3299,6 +3314,60 @@ module MCP assert_empty response.dig(:result, :content) end + test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do + structured_content = JsonObjectValue.new(answer: 42) + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new(nil, structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for non-object structuredContent" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new([], structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for object-like structuredContent" do + structured_content = JsonObjectValue.new(answer: 42) + + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new([], structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + test "list results carry ttlMs and cacheScope when ttl_ms is configured" do # SEP-2549 cache hints. The cacheScope default is "public", matching # the spec default and the Python SDK. diff --git a/test/mcp/tool/response_test.rb b/test/mcp/tool/response_test.rb index 122dbb0..afa7d3b 100644 --- a/test/mcp/tool/response_test.rb +++ b/test/mcp/tool/response_test.rb @@ -16,6 +16,12 @@ module MCP refute response.error? end + test "#content_provided? distinguishes empty content from omitted or nil content" do + assert Response.new([]).content_provided? + refute Response.new.content_provided? + refute Response.new(nil).content_provided? + end + test "#initialize with content and error set to true" do content = [{ type: "text", From 1ddd4d5910be32b3ec2cf87f16c070ec70a01a19 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 21 Jul 2026 11:35:10 +0900 Subject: [PATCH 164/247] Release 1.0.0 This first stable release marks the public API as stable per SEP-1730: breaking changes now ship only in major releases, with the narrow exceptions documented in VERSIONING.md. The release adds ROADMAP.md and VERSIONING.md and contains no behavior changes. Future feature proposals can be incorporated without breaking changes. No incompatibilities have been identified with the known issues and pull requests. --- CHANGELOG.md | 10 ++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb2b5b..e0ba320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-07-24 + +First stable release. The public API is now stable: breaking changes ship only in major releases, +apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md). + +### Added + +- Add `ROADMAP.md` outlining the path toward SEP-1730 (#465) +- Add `VERSIONING.md` documenting the versioning and breaking-change policy (#466) + ## [0.25.0] - 2026-07-18 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 9594104..14e71dc 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.25.0" + VERSION = "1.0.0" end From d3aec1c3e60e0e118236d09d427694e561dfee97 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 25 Jul 2026 14:31:21 +0900 Subject: [PATCH 165/247] Add Stateless Lifecycle Foundations per SEP-2575 ## Motivation and Context SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) replaces the `initialize` handshake with a stateless "modern" lifecycle: every request carries its protocol version, client info, and client capabilities in reserved `_meta` keys, and the server validates each request independently. This change lays the shared vocabulary for that lifecycle without changing any behavior: - `MCP::Configuration` gains `LATEST_MODERN_PROTOCOL_VERSION` ("2026-07-28"), `SUPPORTED_MODERN_PROTOCOL_VERSIONS`, and `Configuration.modern_protocol_version?`. Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS` so `initialize` can never negotiate them and `protocol_version=` keeps rejecting them. - `MCP::ErrorCodes` gains `HEADER_MISMATCH` (-32020), completing the spec's stateless lifecycle error code block alongside the existing -32021/-32022 constants. - New `MCP::RequestEnvelope` parses and validates the per-request `_meta` triple (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`, plus the optional deprecated `logLevel`). A request is classified as modern only when the full triple is present, matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`, so partial or legacy `_meta` entries keep flowing through the legacy path untouched. - New typed errors `Server::UnsupportedProtocolVersionError` (-32022 with `data: { supported:, requested: }`) and `Server::MissingRequiredClientCapabilityError` (-32021 with `data: { requiredCapabilities: }`) reuse the established `RequestHandlerError` `error_code`/`error_data` plumbing (the -32042 URL elicitation precedent). Nothing dispatches through `RequestEnvelope` yet; the server-side modern handling, transport routing, and client support build on this in follow-up changes. Refs #389. ## How Has This Been Tested? New `test/mcp/request_envelope_test.rb` covers the modern-triple classification (full triple, partial triple, string keys, legacy `progressToken`), parsed field access, the -32022 error data shape, and invalid-request errors for incomplete or mistyped triples. `test/mcp/configuration_test.rb` asserts the new constants, the predicate, and that modern versions stay rejected by `protocol_version=`. `test/mcp/server_test.rb` asserts both typed errors surface on the wire with their SEP-2575 codes and data through `Server#handle`. `test/mcp/error_codes_test.rb` pins -32020. ## Breaking Changes None. Constants and classes are additive; no request path changes behavior. --- lib/mcp.rb | 1 + lib/mcp/configuration.rb | 14 ++++ lib/mcp/error_codes.rb | 22 +++--- lib/mcp/request_envelope.rb | 95 +++++++++++++++++++++++++ lib/mcp/server.rb | 35 +++++++++ test/mcp/configuration_test.rb | 15 ++++ test/mcp/error_codes_test.rb | 1 + test/mcp/request_envelope_test.rb | 113 ++++++++++++++++++++++++++++++ test/mcp/server_test.rb | 43 ++++++++++++ 9 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 lib/mcp/request_envelope.rb create mode 100644 test/mcp/request_envelope_test.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 5e0838e..497919d 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -16,6 +16,7 @@ module MCP autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" + autoload :RequestEnvelope, "mcp/request_envelope" autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" autoload :ResultType, "mcp/result_type" diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 3603e6f..98f25e9 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -8,6 +8,20 @@ module MCP ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" + # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575). + # Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`: the modern lifecycle has + # no `initialize` handshake, so these versions are never negotiated (each request carries its own version in `_meta` + # and is validated against this list independently), and `protocol_version=` keeps rejecting them for the same reason. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" + SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze + + class << self + def modern_protocol_version?(version) + SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version) + end + end + attr_writer :exception_reporter, :around_request # @deprecated Use {#around_request=} instead. `instrumentation_callback` diff --git a/lib/mcp/error_codes.rb b/lib/mcp/error_codes.rb index 09bb6fd..603a7a9 100644 --- a/lib/mcp/error_codes.rb +++ b/lib/mcp/error_codes.rb @@ -3,18 +3,24 @@ module MCP # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`. # - # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): - # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not - # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY` - # rejects a request that requires a client capability the request did not declare - # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise - # these codes itself yet. + # All three constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): # - # The values come from the spec's MCP-specific error code block, which is allocated sequentially from - # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here. + # - `HEADER_MISMATCH` rejects an HTTP request whose headers do not match the corresponding body values, + # or whose required headers are missing or malformed (no `error.data`). It is reserved for + # the Streamable HTTP transport, since headers do not exist on stdio. + # - `MISSING_REQUIRED_CLIENT_CAPABILITY` rejects a request that requires a client capability + # the request did not declare (`error.data: { requiredCapabilities: {...} }`). + # Raised via `Server::MissingRequiredClientCapabilityError`. + # - `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server + # does not support (`error.data: { supported: [...], requested: "..." }`). Raised via + # `Server::UnsupportedProtocolVersionError`. + # + # The values come from the spec's MCP-specific error code block, which is allocated sequentially from `-32020` + # toward `-32099`. # # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 module ErrorCodes + HEADER_MISMATCH = -32020 MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 UNSUPPORTED_PROTOCOL_VERSION = -32022 end diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb new file mode 100644 index 0000000..51834e8 --- /dev/null +++ b/lib/mcp/request_envelope.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module MCP + # The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575). + # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version, + # client, and client capabilities through reserved `_meta` keys, and the server validates + # each request independently. Servers MUST NOT infer capabilities from prior requests, + # which is why the envelope is a per-request value object rather than session state. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class RequestEnvelope + PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion" + CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo" + CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities" + + # Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle. + # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format. + LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" + + REQUIRED_META_KEYS = [ + PROTOCOL_VERSION_META_KEY, + CLIENT_INFO_META_KEY, + CLIENT_CAPABILITIES_META_KEY, + ].freeze + + class << self + # A request is classified as modern only when the full REQUIRED triple is present, + # matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`. + # A partial triple is treated as legacy so existing `_meta` usage (`progressToken`, trace context) keeps + # flowing through the legacy path. + def modern?(params) + meta = extract_meta(params) + return false unless meta.is_a?(Hash) + + REQUIRED_META_KEYS.all? { |key| !read(meta, key).nil? } + end + + # Parses and validates the envelope. `request` is only used to enrich the raised error; + # callers dispatching notifications can omit it. + def parse!(params, request: nil) + meta = extract_meta(params) + meta = {} unless meta.is_a?(Hash) + + protocol_version = read(meta, PROTOCOL_VERSION_META_KEY) + client_info = read(meta, CLIENT_INFO_META_KEY) + client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY) + + unless protocol_version.is_a?(String) && client_info.is_a?(Hash) && client_capabilities.is_a?(Hash) + raise Server::RequestHandlerError.new( + "Invalid Request: modern requests require `#{REQUIRED_META_KEYS.join("`, `")}` in `_meta`", + request, + error_type: :invalid_request, + ) + end + + unless Configuration.modern_protocol_version?(protocol_version) + raise Server::UnsupportedProtocolVersionError.new(protocol_version, request) + end + + new( + protocol_version: protocol_version, + client_info: client_info, + client_capabilities: client_capabilities, + log_level: read(meta, LOG_LEVEL_META_KEY), + ) + end + + private + + # `Server#handle` accepts hashes parsed with either symbol or string keys, so read both forms + # (the same tolerance as `Server#handle_cancelled_notification`). + def extract_meta(params) + return unless params.is_a?(Hash) + + meta = params[:_meta] + meta.nil? ? params["_meta"] : meta + end + + def read(meta, key) + value = meta[key.to_sym] + value.nil? ? meta[key] : value + end + end + + attr_reader :protocol_version, :client_info, :client_capabilities, :log_level + + def initialize(protocol_version:, client_info:, client_capabilities:, log_level: nil) + @protocol_version = protocol_version + @client_info = client_info + @client_capabilities = client_capabilities + @log_level = log_level + freeze + end + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 586d0de..0baa973 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -59,6 +59,41 @@ module MCP end end + # Raised when a request carries a protocol version the server does not support under the stateless lifecycle of + # MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }` + # so the client can select a mutually supported version and retry. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class UnsupportedProtocolVersionError < RequestHandlerError + def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS) + super( + "Unsupported protocol version", + request, + error_type: :unsupported_protocol_version, + error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + error_data: { supported: supported, requested: requested || "unknown" }, + ) + end + end + + # Raised when processing a request requires a client capability the request did not declare in `_meta` + # (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities + # the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }` + # listing the missing capabilities. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class MissingRequiredClientCapabilityError < RequestHandlerError + def initialize(required_capabilities, request = nil) + super( + "Missing required client capability", + request, + error_type: :missing_required_client_capability, + error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, + error_data: { requiredCapabilities: required_capabilities }, + ) + end + end + # Raised when a requested resource URI does not exist. Per SEP-2164, # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) # with the requested URI in the error `data` member. Raise this from diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 8270b10..732e5c2 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -66,6 +66,21 @@ module MCP assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end + test "exposes the SEP-2575 modern protocol versions" do + assert_equal "2026-07-28", Configuration::LATEST_MODERN_PROTOCOL_VERSION + assert_equal ["2026-07-28"], Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS + assert Configuration.modern_protocol_version?("2026-07-28") + refute Configuration.modern_protocol_version?("2025-11-25") + end + + test "raises ArgumentError when setting a modern protocol version" do + # Modern versions (SEP-2575) are never negotiated via `initialize`, so they are deliberately not settable as + # the legacy fallback version. + assert_raises(ArgumentError) do + Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) + end + end + test "raises ArgumentError when protocol_version is not a boolean value" do config = Configuration.new exception = assert_raises(ArgumentError) do diff --git a/test/mcp/error_codes_test.rb b/test/mcp/error_codes_test.rb index f00da6a..80826b8 100644 --- a/test/mcp/error_codes_test.rb +++ b/test/mcp/error_codes_test.rb @@ -6,6 +6,7 @@ module MCP class ErrorCodesTest < ActiveSupport::TestCase test "exposes the SEP-2575 stateless lifecycle error codes" do # The exact values are wire vocabulary shared with other SDKs. + assert_equal(-32020, ErrorCodes::HEADER_MISMATCH) assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY) assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION) end diff --git a/test/mcp/request_envelope_test.rb b/test/mcp/request_envelope_test.rb new file mode 100644 index 0000000..e4c2548 --- /dev/null +++ b/test/mcp/request_envelope_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class RequestEnvelopeTest < ActiveSupport::TestCase + test "exposes the reserved SEP-2575 meta key names" do + # The exact strings are wire vocabulary shared with other SDKs. + assert_equal "io.modelcontextprotocol/protocolVersion", RequestEnvelope::PROTOCOL_VERSION_META_KEY + assert_equal "io.modelcontextprotocol/clientInfo", RequestEnvelope::CLIENT_INFO_META_KEY + assert_equal "io.modelcontextprotocol/clientCapabilities", RequestEnvelope::CLIENT_CAPABILITIES_META_KEY + assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY + end + + test ".modern? returns true when the full required triple is present" do + assert RequestEnvelope.modern?(modern_params) + end + + test ".modern? returns true for string keys" do + params = { + "_meta" => { + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientInfo" => { "name" => "c", "version" => "1" }, + "io.modelcontextprotocol/clientCapabilities" => {}, + }, + } + + assert RequestEnvelope.modern?(params) + end + + test ".modern? returns false for a partial triple" do + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") + + refute RequestEnvelope.modern?(params) + end + + test ".modern? returns false for legacy _meta entries such as progressToken" do + refute RequestEnvelope.modern?({ name: "echo", _meta: { progressToken: "token" } }) + end + + test ".modern? returns false without params or _meta" do + refute RequestEnvelope.modern?(nil) + refute RequestEnvelope.modern?({}) + refute RequestEnvelope.modern?({ name: "echo" }) + refute RequestEnvelope.modern?({ _meta: nil }) + end + + test ".parse! returns a frozen envelope with the triple and optional log level" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/logLevel"] = "warning" + + envelope = RequestEnvelope.parse!(params) + + assert_predicate envelope, :frozen? + assert_equal "2026-07-28", envelope.protocol_version + assert_equal({ name: "test_client", version: "1.0.0" }, envelope.client_info) + assert_equal({ elicitation: {} }, envelope.client_capabilities) + assert_equal "warning", envelope.log_level + end + + test ".parse! leaves log_level nil when absent" do + assert_nil RequestEnvelope.parse!(modern_params).log_level + end + + test ".parse! raises UnsupportedProtocolVersionError with the SEP-2575 data shape" do + params = modern_params(version: "2025-11-25") + + error = assert_raises(Server::UnsupportedProtocolVersionError) do + RequestEnvelope.parse!(params) + end + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, error.error_code + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] + assert_equal "2025-11-25", error.error_data[:requested] + end + + test ".parse! raises an invalid request error when the triple is incomplete" do + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientInfo") + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_request, error.error_type + end + + test ".parse! raises an invalid request error when a triple member has the wrong type" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash" + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_request, error.error_type + end + + private + + def modern_params(version: "2026-07-28") + { + name: "echo", + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "test_client", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, + }, + } + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 7281a43..c51c210 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -172,6 +172,49 @@ module MCP end end + test "UnsupportedProtocolVersionError surfaces as -32022 with the SEP-2575 data shape" do + server = Server.new(name: "error_test", tools: [TestTool]) + server.define_tool(name: "unsupported_version_tool") do + raise Server::UnsupportedProtocolVersionError, "1900-01-01" + end + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "unsupported_version_tool" }, + }) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal "Unsupported protocol version", response.dig(:error, :message) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) + assert_equal "1900-01-01", response.dig(:error, :data, :requested) + end + + test "UnsupportedProtocolVersionError reports an unknown requested version" do + error = Server::UnsupportedProtocolVersionError.new(nil) + + assert_equal "unknown", error.error_data[:requested] + end + + test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do + server = Server.new(name: "error_test", tools: [TestTool]) + server.define_tool(name: "missing_capability_tool") do + raise Server::MissingRequiredClientCapabilityError, { elicitation: {} } + end + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "missing_capability_tool" }, + }) + + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) + assert_equal "Missing required client capability", response.dig(:error, :message) + assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities)) + end + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", From a1fd3d42834b84bc3cb0a2d17ec436805e9e9ad5 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 23:20:10 +0900 Subject: [PATCH 166/247] Document non-text content types, binary resources, and elicitation schema options ## Motivation and Context The SDK tiering system (SEP-1730) requires Tier 1 SDKs to document all non-experimental features with examples. A documentation coverage audit against the canonical feature list found that the README.md only showed `type: "text"` content everywhere, leaving several implemented features invisible to users: - Tool responses with image, audio, and embedded resource content (`MCP::Content::Image` / `Audio` / `EmbeddedResource`) - Prompt messages carrying image or embedded resource content - Reading binary resources via `MCP::Resource::BlobContents` - Reading resources through a registered resource template URI - Elicitation `requested_schema` flat-object restrictions and client-side validation behavior - Elicitation default values (SEP-1034) and enum values with `enumNames` (SEP-1330) All of these are existing SDK capabilities; the conformance suite already exercises them. This change documents each with prose and a runnable-style example, closing every documentation gap that does not require new code. Legacy SSE transport remains undocumented because the SDK intentionally does not implement it. ## How Has This Been Tested? Documentation-only change. Every code example was written against the current public API in lib/ (`MCP::Content`, `MCP::Resource::TextContents` / `BlobContents`, `MCP::Prompt::Message`, `create_form_elicitation`) and cross-checked with the working implementations in conformance/server.rb, which pass the conformance suite. ## Breaking Changes None. This only adds sections to README.md. --- README.md | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/README.md b/README.md index bad0473..000e6e2 100644 --- a/README.md +++ b/README.md @@ -872,6 +872,53 @@ class WeatherTool < MCP::Tool end ``` +### Tool Responses with Image, Audio, and Embedded Resources + +Tool responses are not limited to text. The `MCP::Content` module provides `Image`, `Audio`, and `EmbeddedResource` content types, +which serialize to the `image`, `audio`, and `resource` content blocks defined by the MCP spec. Image and audio data is passed as +a base64-encoded string together with its MIME type: + +```ruby +class ChartTool < MCP::Tool + description "Render a chart as a PNG image" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Text.new("Here is the rendered chart:").to_h, + MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h, + ]) + end +end + +class SpeechTool < MCP::Tool + description "Synthesize speech audio" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h, + ]) + end +end +``` + +An embedded resource wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: + +```ruby +class ReportTool < MCP::Tool + description "Return a report as an embedded resource" + + def self.call(server_context:) + contents = MCP::Resource::TextContents.new( + uri: "report://monthly", + mime_type: "application/json", + text: { total: 42 }.to_json, + ) + + MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h]) + end +end +``` + ### Prompts MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. @@ -1010,6 +1057,49 @@ The server will handle prompt listing and execution through the MCP protocol met - `prompts/list` - Lists all registered prompts and their schemas - `prompts/get` - Retrieves and executes a specific prompt with arguments +### Prompts with Image and Embedded Resource Content + +Prompt messages are not limited to text. The same `MCP::Content` types used in tool responses can be used as message content, +letting a prompt template include images or inline resource contents. Unlike tool responses, the content object is passed directly rather than as a hash; +`MCP::Prompt::Message` serializes it when the prompt result is returned: + +```ruby +class CodeReviewPrompt < MCP::Prompt + prompt_name "code_review" + description "Review a source file with an accompanying diagram" + arguments [ + MCP::Prompt::Argument.new(name: "file_uri", description: "URI of the file to review", required: true), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::EmbeddedResource.new( + MCP::Resource::TextContents.new( + uri: args["file_uri"], + mime_type: "text/x-ruby", + text: read_source(args["file_uri"]), + ), + ), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Image.new(architecture_diagram_base64, "image/png"), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please review the code above, using the diagram for context."), + ), + ], + ) + end + end +end +``` + ### Resources MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). @@ -1126,6 +1216,34 @@ server.resources_read_handler do |params| end ``` +### Reading Binary Resources + +For binary resources, respond with a base64-encoded `blob` field instead of `text`. +The `MCP::Resource::TextContents` and `MCP::Resource::BlobContents` classes build the two contents shapes defined by the spec: + +```ruby +server.resources_read_handler do |params| + case params[:uri] + when "file:///logo.png" + [ + MCP::Resource::BlobContents.new( + uri: params[:uri], + mime_type: "image/png", + data: Base64.strict_encode64(File.binread("logo.png")), + ).to_h, + ] + else + [ + MCP::Resource::TextContents.new( + uri: params[:uri], + mime_type: "text/plain", + text: "Hello from example resource!", + ).to_h, + ] + end +end +``` + ### Resource Templates Resource templates follow the same pattern. Class-based templates declare a `uri_template` and @@ -1201,6 +1319,31 @@ server = MCP::Server.new( ) ``` +Registered templates are listed through the `resources/templates/list` protocol method. +To serve reads for URIs that match a template, extract the variable parts of the URI in your `resources_read_handler`: + +```ruby +resource_template = MCP::ResourceTemplate.new( + uri_template: "file:///items/{item_id}", + name: "item", + mime_type: "application/json", +) + +server = MCP::Server.new(name: "my_server", resource_templates: [resource_template]) + +server.resources_read_handler do |params| + if (match = params[:uri].match(%r{\Afile:///items/(?[^/]+)\z})) + [{ + uri: params[:uri], + mimeType: "application/json", + text: { id: match[:item_id] }.to_json, + }] + else + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) + end +end +``` + ### Roots The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. @@ -1793,6 +1936,40 @@ server.define_tool(name: "collect_contact", description: "Collect contact info") end ``` +The `requested_schema` must be a flat object schema: a top-level `type: "object"` whose `properties` are limited to +primitive types (`string`, `number`, `integer`, `boolean`). Nested objects and arrays are not allowed, which keeps +the schema simple enough for clients to render as a form. Per the MCP specification, the client validates +the user's input against this schema before returning it, so the `content` of an `accept` response matches the requested shape. + +#### Default Values and Enums + +Properties may declare a `default` value (SEP-1034), which clients use to pre-fill the form. +String properties may declare `enum` values, optionally with human-readable `enumNames` (SEP-1330), which clients render as a choice list: + +```ruby +server.define_tool(name: "configure_deploy", description: "Configure a deployment") do |server_context:| + result = server_context.create_form_elicitation( + message: "Configure the deployment", + requested_schema: { + type: "object", + properties: { + replicas: { type: "integer", default: 3 }, + verbose: { type: "boolean", default: false }, + environment: { + type: "string", + enum: ["dev", "staging", "prod"], + enumNames: ["Development", "Staging", "Production"], + default: "dev", + }, + }, + required: ["environment"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Deploying to #{result[:content][:environment]}" }]) +end +``` + #### URL Mode URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: From 3591acdfaab217a71beee4442045bd48cfd66862 Mon Sep 17 00:00:00 2001 From: rickreyhsig-wealthbox Date: Mon, 27 Jul 2026 09:54:54 -0400 Subject: [PATCH 167/247] Expose server tool annotations on MCP::Client::Tool Client#tools/#list_tools built Tool objects without an annotations field even though the server includes annotations in every tools/list response. Add the reader and pass the raw hash through, mirroring how output_schema is already handled. --- lib/mcp/client.rb | 1 + lib/mcp/client/tool.rb | 5 +++-- test/mcp/client/tool_test.rb | 20 ++++++++++++++++++++ test/mcp/client_test.rb | 3 +++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index f568b25..2d46a31 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -131,6 +131,7 @@ module MCP description: tool["description"], input_schema: tool["inputSchema"], output_schema: tool["outputSchema"], + annotations: tool["annotations"], ) end diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index b348d4d..bfefda8 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -3,13 +3,14 @@ module MCP class Client class Tool - attr_reader :name, :description, :input_schema, :output_schema + attr_reader :name, :description, :input_schema, :output_schema, :annotations - def initialize(name:, description:, input_schema:, output_schema: nil) + def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil) @name = name @description = description @input_schema = input_schema @output_schema = output_schema + @annotations = annotations end end end diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb index 88e82e9..f2025ab 100644 --- a/test/mcp/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -33,6 +33,24 @@ module MCP assert_nil(@tool.output_schema) end + def test_annotations_returns_nil_when_not_provided + assert_nil(@tool.annotations) + end + + def test_annotations_returns_annotations_when_provided + tool_with_annotations = Tool.new( + name: "test_tool_with_annotations", + description: "A test tool with annotations", + input_schema: { "type" => "object" }, + annotations: { "readOnlyHint" => true, "title" => "Test Tool" }, + ) + + assert_equal( + { "readOnlyHint" => true, "title" => "Test Tool" }, + tool_with_annotations.annotations, + ) + end + def test_output_schema_returns_output_schema_when_provided tool_with_output = Tool.new( name: "test_tool_with_output", @@ -53,12 +71,14 @@ module MCP description: "A tool with all parameters", input_schema: { "type" => "object" }, output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, + annotations: { "readOnlyHint" => true }, ) assert_equal("full_tool", tool.name) assert_equal("A tool with all parameters", tool.description) assert_equal({ "type" => "object" }, tool.input_schema) assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema) + assert_equal({ "readOnlyHint" => true }, tool.annotations) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 296c0f5..68caba8 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -86,6 +86,7 @@ module MCP "description" => "tool1", "inputSchema" => {}, "outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } }, + "annotations" => { "readOnlyHint" => true, "title" => "Tool One" }, }, { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, ], @@ -103,8 +104,10 @@ module MCP assert_equal(2, tools.size) assert_equal("tool1", tools.first.name) assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema) + assert_equal({ "readOnlyHint" => true, "title" => "Tool One" }, tools.first.annotations) assert_equal("tool2", tools.last.name) assert_nil(tools.last.output_schema) + assert_nil(tools.last.annotations) end def test_tools_returns_empty_array_when_no_tools From c2879fb640d05a826b33e11966e56ef958047c2e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 29 Jul 2026 17:12:24 +0900 Subject: [PATCH 168/247] Fix flash of unstyled content when loading the docs site ## Motivation and Context Opening https://ruby.sdk.modelcontextprotocol.io/ briefly showed a black half-circle at the bottom of the screen. The theme preference script in `head_custom.html` called `window.jtd.setTheme` on every page load. That helper rewrites the `href` of the render-blocking color stylesheet link, which detaches the loaded stylesheet and refetches it, so the page paints unstyled until the replacement arrives. During that window the search magnifier SVG, sized only by CSS, rendered at its fallback size and appeared as a large black half-circle near the bottom of the viewport. The site sets no `color_scheme`, so the default stylesheet is identical to the light one and most visitors paid the flash for a no-op swap. This change replaces the `jtd.setTheme` call with a local swapper that: - Leaves the stylesheet untouched when the resolved theme already matches (treating `default` as `light`). - Swaps non-destructively otherwise: the new link is inserted next to the current one and the old link is removed only after the new stylesheet has loaded. On error the new link is dropped and the page keeps its current styles. - Sets `blocking="render"` on the new link so supporting browsers hold first paint until the dark stylesheet arrives. It also pre-applies the dark background and text colors in `custom.scss`, keyed on the `data-theme` attribute that is set synchronously in the head, to soften the light-colored first paint for dark-preference visitors in browsers without `blocking="render"` support. ## How Has This Been Tested? Mirrored the live site locally, patched the inline script with the new code, and drove it in Chrome: - Unpatched baseline reproduced the old behavior: the link `href` was rewritten and `just-the-docs-light.css` was fetched in addition to `just-the-docs-default.css`. - Patched light and system-light loads keep the original link untouched with no extra stylesheet request. - Patched dark loads settle on a single `just-the-docs-dark.css` link, with `jtd.getTheme()` still reporting the right theme. - Rapid toggling keeps at most two color stylesheet links and settles on exactly one matching the final preference. - With `just-the-docs-dark.css` returning 404, the failed link is dropped and the page stays styled. ## Breaking Changes None. --- docs/_includes/head_custom.html | 79 +++++++++++++++++++++++++++++++-- docs/_sass/custom/custom.scss | 10 +++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html index 701d0ae..4a919ce 100644 --- a/docs/_includes/head_custom.html +++ b/docs/_includes/head_custom.html @@ -40,6 +40,81 @@ return mediaQuery.matches ? "dark" : "light"; } + const colorSchemePattern = /just-the-docs-(default|light|dark)\.css(\?.*)?$/; + let pendingColorLink = null; + + function findColorStylesheet() { + const links = document.querySelectorAll('link[rel="stylesheet"]'); + for (let index = 0; index < links.length; index += 1) { + if (colorSchemePattern.test(links[index].getAttribute("href") || "")) { + return links[index]; + } + } + + return null; + } + + function stylesheetTheme(link) { + const match = colorSchemePattern.exec(link.getAttribute("href") || ""); + const scheme = match ? match[1] : "default"; + return scheme === "default" ? "light" : scheme; + } + + /* + * Swap the color stylesheet without going through jtd.setTheme, which + * rewrites the href of the render-blocking link and lets the page paint + * unstyled until the replacement CSS arrives. The site has no color_scheme, + * so the default stylesheet equals the light one and needs no swap at all; + * for dark, keep the old sheet applied until the new one has loaded. + */ + function setColorStylesheet(theme) { + const currentLink = findColorStylesheet(); + if (!currentLink) { + return; + } + + if (pendingColorLink) { + if (pendingColorLink.parentNode) { + pendingColorLink.parentNode.removeChild(pendingColorLink); + } + pendingColorLink = null; + } + + if (stylesheetTheme(currentLink) === theme) { + return; + } + + const newLink = document.createElement("link"); + newLink.setAttribute("rel", "stylesheet"); + newLink.setAttribute("blocking", "render"); + newLink.setAttribute( + "href", + currentLink.getAttribute("href").replace(colorSchemePattern, "just-the-docs-" + theme + ".css") + ); + + newLink.addEventListener("load", function() { + if (pendingColorLink !== newLink) { + return; + } + pendingColorLink = null; + if (currentLink.parentNode) { + currentLink.parentNode.removeChild(currentLink); + } + }); + + newLink.addEventListener("error", function() { + if (pendingColorLink === newLink) { + pendingColorLink = null; + } + if (newLink.parentNode) { + newLink.parentNode.removeChild(newLink); + } + }); + + currentLink.parentNode.insertBefore(newLink, currentLink.nextSibling); + pendingColorLink = newLink; + } + function nextPreference(preference) { const index = validPreferences.indexOf(preference); if (index === -1 || index === validPreferences.length - 1) { @@ -62,9 +137,7 @@ const safePreference = isValidPreference(preference) ? preference : "system"; const theme = resolvedTheme(safePreference); - if (window.jtd && typeof window.jtd.setTheme === "function") { - window.jtd.setTheme(theme); - } + setColorStylesheet(theme); document.documentElement.setAttribute("data-theme-preference", safePreference); document.documentElement.setAttribute("data-theme", theme); setThemeToggleLabel(safePreference); diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss index 5149a2a..498de1b 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -62,6 +62,16 @@ color-scheme: dark; } +/* + * Applied before the dark stylesheet finishes loading, so browsers that + * do not support blocking="render" avoid a light-colored first paint. + * Values match $grey-dk-300 and $grey-lt-300 from the dark color scheme. + */ +:root[data-theme="dark"] body { + background-color: #27262b; + color: #e6e1e8; +} + :root[data-theme="light"] { color-scheme: light; } From 5b4729294c4428ddb081c68b1deac4ebaea92755 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 12:10:07 +0900 Subject: [PATCH 169/247] Handle the SEP-2575 Modern Request Envelope in the Server Core ## Motivation and Context Second step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release, building on the `MCP::RequestEnvelope` foundations. `Server#handle_request` now lifts the per-request `_meta` envelope before dispatch: a request whose `_meta` carries the full required triple is validated (raising `-32022` with `data: { supported:, requested: }` for unsupported versions) and its envelope is threaded into `tools/call`, `prompts/get`, `completion/complete`, `resources/read`, subscribe/unsubscribe, and custom method handlers via `MCP::ServerContext`. A partial triple keeps flowing through the legacy path untouched, so existing `_meta` usage (`progressToken`, trace context) is unaffected. `MCP::ServerContext` gains per-request readers and a capability guard: - `modern?`, `protocol_version`, `client_info`, and `client_capabilities` read from the envelope first and fall back to session state stored by `initialize` on legacy requests. The envelope always wins because servers MUST NOT infer client state from prior requests; nothing is written to the session on the modern path. - `require_client_capability!(*path)` raises `Server::MissingRequiredClientCapabilityError` (`-32021` with `data: { requiredCapabilities: }`) when the request did not declare the capability. - `notify_log_message` honors the per-request `io.modelcontextprotocol/logLevel`: on modern requests without it, the server MUST NOT send `notifications/message`, and an insufficient level drops the message the same way. `MCP::ServerSession` gains the connection-era lock of the dual-era serving model: `era` is `nil` until the first era-distinctive message succeeds, a successful `initialize` locks `:legacy` as a side effect of `mark_initialized!`, `lock_era!` refuses to flip an established era, and transports can construct per-request sessions with `era: :modern`. On a modern-locked session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake) and the envelope triple becomes required for every other request except `server/discover`. The existing plain `RuntimeError` raises in `ServerSession#list_roots` and friends are intentionally unchanged: converting them to `RequestHandlerError` subclasses would break callers rescuing `RuntimeError`. Typed `-32021` errors are scoped to the new envelope-based guard. Refs #389. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` cover the wire behavior through `Server#handle`: envelope data exposure without session mutation, `-32022` for unsupported envelope versions, partial triples staying legacy, the envelope requirement and `initialize` rejection on modern-locked sessions, the `server/discover` exemption, `require_client_capability!` returning `-32021` with the `requiredCapabilities` data shape, and the `ServerSession` era-lock transitions (including preset `era: :modern` and invalid values). New tests in `test/mcp/server_context_test.rb` cover the per-request logLevel gate (absent, insufficient, and sufficient levels), the envelope-first/session-fallback readers, and string-keyed capability matching in `require_client_capability!`. ## Breaking Changes None. All new keyword arguments default to `nil`, legacy requests take exactly the same code path as before, and the era lock only constrains sequences that were previously impossible (modern-era traffic). Fix a Ruby 2.7 ArgumentError When Raising UnsupportedProtocolVersionError ## Motivation and Context On Ruby 2.7, a method that declares a keyword parameter splits a trailing symbol-keyed Hash positional argument into keywords. `UnsupportedProtocolVersionError#initialize` declared `supported:`, so passing the request Hash as the second positional argument raised `ArgumentError: unknown keywords: :name, :arguments, :_meta` inside the error constructor, and the intended `-32022` response surfaced as a `-32603` internal error on the Ruby 2.7 CI job. No caller overrides `supported:`; drop the keyword parameter and read `Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS` directly, matching the keyword-free shape of `ResourceNotFoundError` and `MissingRequiredClientCapabilityError`. ## How Has This Been Tested? - Reproduced the `-32603` responses on Ruby 2.7.8 and confirmed both now return `-32022` - Added a regression test constructing the error with a symbol-keyed request Hash - `bundle exec rake test` on Ruby 2.7.8: 1467 runs, 0 failures ## Breaking Changes None. The `supported:` keyword argument was never passed by any caller. --- lib/mcp/server.rb | 69 ++++++++++--- lib/mcp/server_context.rb | 61 ++++++++++- lib/mcp/server_session.rb | 29 +++++- test/mcp/server_context_test.rb | 78 ++++++++++++++ test/mcp/server_test.rb | 175 ++++++++++++++++++++++++++++++++ 5 files changed, 394 insertions(+), 18 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 0baa973..910c8d8 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -65,13 +65,15 @@ module MCP # # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 class UnsupportedProtocolVersionError < RequestHandlerError - def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS) + # No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash + # into keywords and fail with "unknown keywords". + def initialize(requested, request = nil) super( "Unsupported protocol version", request, error_type: :unsupported_protocol_version, error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, - error_data: { supported: supported, requested: requested || "unknown" }, + error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" }, ) end end @@ -560,25 +562,26 @@ module MCP server_context: { request: request }, exception_already_reported: ->(e) { reported_exception.equal?(e) }, ) do + envelope = lift_request_envelope(params, method: method, session: session) result = case method when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)) + build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) {} when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::PROMPTS_GET - get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::COMPLETION_COMPLETE - complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) end client = session&.client || @client add_instrumentation_data(client: client) if client @@ -609,6 +612,34 @@ module MCP } end + # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries + # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. + # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is + # pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is + # rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for + # every other request. + def lift_request_envelope(params, method:, session:) + return if Methods.notification?(method) + return if method == Methods::SERVER_DISCOVER + + modern_session = session.respond_to?(:era) && session.era == :modern + + if modern_session && method == Methods::INITIALIZE + requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil + raise UnsupportedProtocolVersionError.new(requested, params) + end + + if RequestEnvelope.modern?(params) + RequestEnvelope.parse!(params, request: params) + elsif modern_session + raise RequestHandlerError.new( + "Invalid Request: modern sessions require the SEP-2575 `_meta` envelope", + params, + error_type: :invalid_request, + ) + end + end + def handle_cancelled_notification(params, session: nil) return unless session return unless params.is_a?(Hash) @@ -748,7 +779,7 @@ module MCP apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end - def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) tool_name = request[:name] tool = tools[tool_name] @@ -781,7 +812,7 @@ module MCP progress_token = request.dig(:_meta, :progressToken) response = call_tool_with_args( - tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation + tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope ) result = response.to_h validate_tool_call_result!(tool, result) @@ -808,7 +839,7 @@ module MCP apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end - def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt @@ -826,6 +857,7 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) call_prompt_template_with_args(prompt, prompt_args, server_context) @@ -921,7 +953,7 @@ module MCP { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result) end - def complete(params, session: nil, related_request_id: nil, cancellation: nil) + def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) validate_completion_params!(params) result = dispatch_optional_context_handler( @@ -930,6 +962,7 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) normalize_completion_result(result) @@ -938,13 +971,14 @@ module MCP # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or # `server_context.raise_if_cancelled!`. - def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil) + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) dispatch_optional_context_handler( @handlers[Methods::RESOURCES_READ], request, session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) end @@ -952,7 +986,7 @@ module MCP # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. - def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil) + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) return handler.call(params) unless handler_declares_server_context?(handler) server_context = build_server_context( @@ -960,6 +994,7 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) handler.call(params, server_context: server_context) end @@ -984,7 +1019,7 @@ module MCP # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers # (progress, cancellation, nested server-to-client requests). - def build_server_context(request:, session:, related_request_id:, cancellation:) + def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil) meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) @@ -994,6 +1029,7 @@ module MCP notification_target: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) end @@ -1053,7 +1089,7 @@ module MCP end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. @@ -1068,6 +1104,7 @@ module MCP notification_target: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) tool.call(**args, server_context: server_context) else diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index dc5b20a..d2abb52 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -4,12 +4,17 @@ module MCP class ServerContext attr_reader :cancellation - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil) + # The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern; + # `nil` on legacy requests. + attr_reader :envelope + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id @cancellation = cancellation + @envelope = envelope end def cancelled? @@ -20,6 +25,52 @@ module MCP @cancellation&.raise_if_cancelled! end + # Whether the current request follows the stateless modern lifecycle (SEP-2575). + def modern? + !@envelope.nil? + end + + # Client identity for the current request. Modern requests carry it in the `_meta` envelope; + # legacy sessions fall back to the state stored by `initialize`. The envelope always wins + # because servers MUST NOT infer identity from prior requests. + def client_info + return @envelope.client_info if @envelope + + @notification_target.client if @notification_target.respond_to?(:client) + end + + # Client capabilities for the current request, with the same envelope-first resolution as {#client_info}. + def client_capabilities + return @envelope.client_capabilities if @envelope + + @notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities) + end + + # The protocol version the current request was made with. `nil` on legacy requests, + # where the version is a session-level negotiation result rather than per-request data. + def protocol_version + @envelope&.protocol_version + end + + # Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys, + # e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError` + # (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared. + def require_client_capability!(*path) + raise ArgumentError, "at least one capability key is required" if path.empty? + + declared = client_capabilities + value = path.reduce(declared) do |acc, key| + break unless acc.is_a?(Hash) + + symbol_value = acc[key.to_sym] + symbol_value.nil? ? acc[key.to_s] : symbol_value + end + return unless value.nil? + + required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } } + raise Server::MissingRequiredClientCapabilityError, required + end + # Reports progress for the current tool operation. # The notification is automatically scoped to the originating session. # @@ -41,6 +92,14 @@ module MCP def notify_log_message(data:, level:, logger: nil) return unless @notification_target + # Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`, + # the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops + # the message the same way. Session- or server-level gating still applies downstream on delegation. + if @envelope + threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level) + return unless threshold&.valid_level? && threshold.should_notify?(level) + end + @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 754c1d0..7cbf2fb 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -7,12 +7,22 @@ module MCP # Holds per-connection state for a single client session. # Created by the transport layer; delegates request handling to the shared `Server`. class ServerSession + ERAS = [:legacy, :modern].freeze + attr_reader :session_id, :client, :logging_message_notification - def initialize(server:, transport:, session_id: nil) + # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds, + # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions + # with `era: :modern` up front. + attr_reader :era + + def initialize(server:, transport:, session_id: nil, era: nil) + validate_era!(era) if era + @server = server @transport = transport @session_id = session_id + @era = era @client = nil @client_capabilities = nil @logging_message_notification = nil @@ -31,6 +41,19 @@ module MCP # (the initialization phase MUST be the first interaction). def mark_initialized! @initialized = true + # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575), + # so it also locks the connection era. + @era ||= :legacy + end + + # One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises, + # because a connection can never change eras. + def lock_era!(era) + validate_era!(era) + return if @era == era + raise "Session era already locked to #{@era}" if @era + + @era = era end # Registers a `Cancellation` token for an in-flight request. @@ -236,6 +259,10 @@ module MCP private + def validate_era!(era) + raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era) + end + # Forwards `send_notification` to the transport with only the kwargs the transport's method signature # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)` # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:` diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 9125805..07d6072 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -311,6 +311,71 @@ module MCP assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } end + test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do + # Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send + # any `notifications/message` for the request. + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: nil) + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext#notify_log_message drops messages below the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: "error") + + server_context.notify_log_message(data: "test", level: "info") + end + + test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).with( + data: "test", level: "error", logger: nil, related_request_id: nil, + ).once + server_context = build_modern_server_context(notification_target, log_level: "warning") + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext exposes envelope data and falls back to the session on legacy requests" do + notification_target = mock + notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" }) + notification_target.stubs(:client_capabilities).returns({ roots: {} }) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) + modern_context = build_modern_server_context(notification_target) + + refute_predicate legacy_context, :modern? + assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info) + assert_equal({ roots: {} }, legacy_context.client_capabilities) + assert_nil legacy_context.protocol_version + + assert_predicate modern_context, :modern? + assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info) + assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities) + assert_equal "2026-07-28", modern_context.protocol_version + end + + test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "c", version: "1" }, + client_capabilities: { "elicitation" => { "form" => {} } }, + ) + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope) + + assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) } + assert_raises(ArgumentError) { server_context.require_client_capability! } + error = assert_raises(Server::MissingRequiredClientCapabilityError) do + server_context.require_client_capability!(:sampling) + end + assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data) + end + test "ServerContext#notify_resources_updated delegates to notification_target" do notification_target = mock notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once @@ -1000,5 +1065,18 @@ module MCP assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World", response[:result][:messages][0][:content][:text] end + + private + + def build_modern_server_context(notification_target, log_level: nil) + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "modern_client", version: "2.0" }, + client_capabilities: { elicitation: { form: {} } }, + log_level: log_level, + ) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c51c210..d8cce38 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -197,6 +197,17 @@ module MCP assert_equal "unknown", error.error_data[:requested] end + test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do + # On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split + # into keywords and raise an ArgumentError. + request = { name: "echo", arguments: {}, _meta: {} } + + error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request) + + assert_equal "1900-01-01", error.error_data[:requested] + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] + end + test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do server = Server.new(name: "error_test", tools: [TestTool]) server.define_tool(name: "missing_capability_tool") do @@ -215,6 +226,152 @@ module MCP assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities)) end + test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do + server = Server.new(name: "modern_test", tools: []) + received = nil + server.define_tool(name: "modern_tool") do |server_context:| + received = { + modern: server_context.modern?, + client_info: server_context.client_info, + client_capabilities: server_context.client_capabilities, + protocol_version: server_context.protocol_version, + } + Tool::Response.new([{ type: "text", text: "ok" }]) + end + session = ServerSession.new(server: server, transport: mock) + + response = server.handle( + modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }), + session: session, + ) + + refute_nil response[:result] + assert received[:modern] + assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info]) + assert_equal({ elicitation: {} }, received[:client_capabilities]) + assert_equal "2026-07-28", received[:protocol_version] + # Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored. + assert_nil session.client + refute_predicate session, :initialized? + end + + test "#handle tools/call with an unsupported envelope version returns -32022" do + server = Server.new(name: "modern_test", tools: []) + called = false + server.define_tool(name: "modern_tool") do + called = true + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01")) + + refute called + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) + assert_equal "2027-01-01", response.dig(:error, :data, :requested) + end + + test "#handle treats a partial modern triple as a legacy request" do + server = Server.new(name: "modern_test", tools: []) + received_context = nil + server.define_tool(name: "modern_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "modern_tool", + arguments: {}, + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }) + + refute_nil response[:result] + refute_predicate received_context, :modern? + end + + test "#handle requires the envelope for requests on a modern-locked session" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } + session = ServerSession.new(server: server, transport: mock, era: :modern) + + response = server.handle( + { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } }, + session: session, + ) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code) + end + + test "#handle rejects initialize on a modern-locked session with -32022" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + response = @server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } }, + }, + session: session, + ) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal "2025-11-25", response.dig(:error, :data, :requested) + end + + test "#handle server/discover succeeds without an envelope on a modern-locked session" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + + refute_nil response[:result] + end + + test "#handle tools/call enforces require_client_capability! from the envelope" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "guarded_tool") do |server_context:| + server_context.require_client_capability!(:elicitation, :form) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + declared = server.handle( + modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }), + ) + undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" })) + + refute_nil declared[:result] + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code) + assert_equal( + { elicitation: { form: {} } }, + undeclared.dig(:error, :data, :requiredCapabilities), + ) + end + + test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do + session = ServerSession.new(server: @server, transport: mock) + + assert_nil session.era + session.mark_initialized! + assert_equal :legacy, session.era + + session.lock_era!(:legacy) + assert_equal :legacy, session.era + assert_raises(RuntimeError) { session.lock_era!(:modern) } + end + + test "ServerSession accepts a preset era and validates era values" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + assert_equal :modern, session.era + assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) } + assert_raises(ArgumentError) { session.lock_era!(:future) } + end + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", @@ -3726,5 +3883,23 @@ module MCP refute Apps.client_supports?(server.client_capabilities) end + + private + + # Builds a request carrying the SEP-2575 modern `_meta` envelope. + def modern_request(method, params, version: "2026-07-28", capabilities: {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + ), + } + end end end From dc25610162450de36f1f173d167f5f44337f8a73 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 31 Jul 2026 12:27:58 +0900 Subject: [PATCH 170/247] Support 2026-07-28 as the Latest Protocol Version ## Motivation and Context Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25, the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header, and `server/discover` omits the version from `supportedVersions`. Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once, since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model: negotiable through the legacy `initialize` handshake, and simultaneously the version of the per-request modern lifecycle. Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle over the transports, and are being built separately. This change deliberately limits itself to the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit. Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate needs rework on top of this change. Closes #474 ## How Has This Been Tested? New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`), a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation (`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted (`configuration_test.rb`). `bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30. The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO to remove it when the framework catches up. ## Breaking Changes Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported and negotiable; clients that request it are unaffected. --- conformance/expected_failures.yml | 6 ++++- docs/building-clients.md | 4 +-- lib/mcp/configuration.rb | 11 ++++---- test/mcp/configuration_test.rb | 15 +++++------ .../streamable_http_transport_test.rb | 26 +++++++++++++++++++ test/mcp/server_test.rb | 14 ++++++++++ 6 files changed, 60 insertions(+), 16 deletions(-) diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 0bbbbfb..371853d 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,2 +1,6 @@ server: [] -client: [] +client: + # TODO: The conformance framework's client-initialization check pins the expected requested version to 2025-11-25, + # while this SDK's latest protocol version is 2026-07-28. Remove once the @modelcontextprotocol/conformance package + # accepts 2026-07-28. + - initialize diff --git a/docs/building-clients.md b/docs/building-clients.md index 6b4c7d0..9dfaf0d 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -22,7 +22,7 @@ Call `MCP::Client#connect` to perform the MCP [initialization handshake](https:/ ```ruby client.connect -# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } +# => { "protocolVersion" => "2026-07-28", "capabilities" => {...}, "serverInfo" => {...} } client.connected? # => true client.server_info # => cached InitializeResult @@ -99,7 +99,7 @@ After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` heade ```ruby http_transport.session_id # => "abc123..." -http_transport.protocol_version # => "2025-11-25" +http_transport.protocol_version # => "2026-07-28" ``` If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 98f25e9..ed6d430 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -2,16 +2,17 @@ module MCP class Configuration - LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25" + LATEST_STABLE_PROTOCOL_VERSION = "2026-07-28" SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ - LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", + LATEST_STABLE_PROTOCOL_VERSION, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575). - # Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`: the modern lifecycle has - # no `initialize` handshake, so these versions are never negotiated (each request carries its own version in `_meta` - # and is validated against this list independently), and `protocol_version=` keeps rejecting them for the same reason. + # 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize` + # handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern + # lifecycle, where each request carries its own version in `_meta` and is validated against this list + # independently, with no handshake. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 732e5c2..86231f7 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -54,7 +54,7 @@ module MCP Configuration.new(protocol_version: "DRAFT-2025-v3") end - assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "raises ArgumentError when protocol_version is not a supported protocol version" do @@ -63,7 +63,7 @@ module MCP custom_version = "2025-03-27" config.protocol_version = custom_version end - assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "exposes the SEP-2575 modern protocol versions" do @@ -73,12 +73,11 @@ module MCP refute Configuration.modern_protocol_version?("2025-11-25") end - test "raises ArgumentError when setting a modern protocol version" do - # Modern versions (SEP-2575) are never negotiated via `initialize`, so they are deliberately not settable as - # the legacy fallback version. - assert_raises(ArgumentError) do - Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) - end + test "accepts 2026-07-28 as the protocol version" do + # 2026-07-28 serves both lifecycles of the dual-era model (SEP-2575), so it is negotiable through + # the legacy `initialize` handshake and settable as the fallback version. + config = Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) + assert_equal "2026-07-28", config.protocol_version end test "raises ArgumentError when protocol_version is not a boolean value" do diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 099686e..4912ceb 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1993,6 +1993,32 @@ module MCP assert_equal 200, response[0] end + test "POST request with the 2026-07-28 MCP-Protocol-Version header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, + ) + init_response = @transport.handle_request(init_request) + assert_equal "2026-07-28", JSON.parse(init_response[2][0])["result"]["protocolVersion"] + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + test "POST request without MCP-Protocol-Version header succeeds" do init_request = create_rack_request( "POST", diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c51c210..4d9c5b0 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2036,6 +2036,20 @@ module MCP assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] end + test "server negotiates 2026-07-28 when the client requests it via initialize" do + server = Server.new(name: "test_server") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2026-07-28"), + } + + response = server.handle(request) + assert_equal "2026-07-28", response[:result][:protocolVersion] + end + test "server removes description and icons from server_info when negotiating to 2025-06-18" do server = Server.new( name: "test_server", From 1fa8673a58cdd4a38db2f61e96024805c05a90d2 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 1 Aug 2026 11:27:48 +0900 Subject: [PATCH 171/247] Release 1.1.0 This release primarily addresses https://github.com/modelcontextprotocol/ruby-sdk/issues/474. Work on full support for the 2026-07-28 specification, including stateless mode, is ongoing and will continue in future releases. --- CHANGELOG.md | 11 +++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ba320..27e2be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-08-01 + +### Added + +- Support 2026-07-28 as the Latest Protocol Version (#476) +- Expose server tool annotations on MCP::Client::Tool (#445) + +### Fixed + +- Preserve explicit tool response content (#469) + ## [1.0.0] - 2026-07-24 First stable release. The public API is now stable: breaking changes ship only in major releases, diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 14e71dc..2d7cb5f 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.0.0" + VERSION = "1.1.0" end From 09545f1272bedf6ad7a595f54f1884f81f2610ab Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 12:13:44 +0900 Subject: [PATCH 172/247] Serve both lifecycle eras over stdio with an era lock per SEP-2575 ## Motivation and Context Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era for its lifetime. - Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`, keeping protocol-level error responses byte-identical. - A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`). A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`. Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked, so a client probe can still fall back to the other era. - Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side: a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after a modern lock is `-32600`). - `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace. The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop. Refs #389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips: legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover` then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request, no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards), the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions. `bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes. ## Breaking Changes None. Legacy clients send `initialize` first and take the same code path as before; the era lock only constrains message sequences that mix lifecycles on one connection, which no legacy client produces. --- lib/mcp/server.rb | 21 ++- lib/mcp/server/transports/stdio_transport.rb | 41 +++++- .../server/transports/stdio_transport_test.rb | 135 ++++++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 910c8d8..d1800d1 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -615,23 +615,32 @@ module MCP # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is - # pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is - # rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for - # every other request. + # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules: + # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake) + # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as + # an invalid request because a connection can never change eras. def lift_request_envelope(params, method:, session:) return if Methods.notification?(method) return if method == Methods::SERVER_DISCOVER - modern_session = session.respond_to?(:era) && session.era == :modern + era = session.respond_to?(:era) ? session.era : nil - if modern_session && method == Methods::INITIALIZE + if era == :modern && method == Methods::INITIALIZE requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil raise UnsupportedProtocolVersionError.new(requested, params) end if RequestEnvelope.modern?(params) + if era == :legacy + raise RequestHandlerError.new( + "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", + params, + error_type: :invalid_request, + ) + end + RequestEnvelope.parse!(params, request: params) - elsif modern_session + elsif era == :modern raise RequestHandlerError.new( "Invalid Request: modern sessions require the SEP-2575 `_meta` envelope", params, diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index ec2bd6d..8c75812 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -49,7 +49,9 @@ module MCP end break if line.nil? - response = @session.handle_json(line.strip) + line = line.strip + parsed = parse_line(line) + response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line) send_response(response) if response end rescue Interrupt @@ -90,6 +92,12 @@ module MCP # cancellation has very limited value here regardless; servers that need cancellation propagation for nested # server-to-client requests should use `StreamableHTTPTransport`. def send_request(method, params = nil) + # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; + # multi round-trip `input_required` results (SEP-2322) replace them. + if @session && @session.era == :modern + raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." + end + request_id = generate_request_id request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params @@ -116,7 +124,7 @@ module MCP return parsed[:result] else - response = @session ? @session.handle(parsed) : @server.handle(parsed) + response = @session ? dispatch_with_era(parsed) : @server.handle(parsed) send_response(response) if response end end @@ -143,6 +151,35 @@ module MCP line end + + # Parses a frame once so era classification can inspect its method and `_meta`. + # Returns `nil` for frames that are not JSON objects; those fall back to + # `ServerSession#handle_json` so protocol-level error responses stay identical. + def parse_line(line) + parsed = JSON.parse(line, symbolize_names: true) + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + end + + # Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks + # the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover` + # or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames + # (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock) + # are rejected in-band by `Server#lift_request_envelope`. + def dispatch_with_era(parsed) + response = @session.handle(parsed) + lock_modern_era_on_success(parsed, response) + response + end + + def lock_modern_era_on_success(parsed, response) + return if @session.era + return if !response.is_a?(Hash) || response.key?(:error) + return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params]) + + @session.lock_era!(:modern) + end end end end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index cdca209..1b7d66d 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -7,6 +7,7 @@ module MCP module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper setup do configuration = MCP::Configuration.new @@ -550,6 +551,140 @@ module MCP $stdout = original_stdout end end + + test "locks the legacy era on a successful initialize and rejects a later modern envelope" do + responses = run_transport_session([ + initialize_request(id: 1), + modern_ping_request(id: 2), + ]) + + refute responses[0].key?(:error) + assert_equal :legacy, session_era + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + end + + test "initialize negotiating 2026-07-28 still locks the legacy era" do + # 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through + # the legacy handshake locks `:legacy`, so a later modern envelope is still rejected. + responses = run_transport_session([ + initialize_request(id: 1, protocol_version: "2026-07-28"), + modern_ping_request(id: 2), + ]) + + assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion) + assert_equal :legacy, session_era + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + end + + test "locks the modern era on a successful server/discover and rejects a later initialize with -32022" do + responses = run_transport_session([ + { jsonrpc: "2.0", method: "server/discover", id: 1 }, + initialize_request(id: 2), + modern_ping_request(id: 3), + ]) + + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) + assert_equal :modern, session_era + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported) + refute responses[2].key?(:error) + end + + test "locks the modern era on a successful request carrying the modern envelope" do + responses = run_transport_session([modern_ping_request(id: 1)]) + + refute responses[0].key?(:error) + assert_equal :modern, session_era + end + + test "does not lock an era when the era-distinctive request fails" do + # An unsupported envelope version fails with -32022, so the connection stays unlocked + # and a legacy initialize can still succeed afterwards. + responses = run_transport_session([ + modern_ping_request(id: 1, version: "2027-01-01"), + initialize_request(id: 2), + ]) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code) + refute responses[1].key?(:error) + assert_equal :legacy, session_era + end + + test "requires the modern envelope after a modern era lock" do + responses = run_transport_session([ + modern_ping_request(id: 1), + { jsonrpc: "2.0", method: "ping", id: 2 }, + ]) + + refute responses[0].key?(:error) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + end + + test "#send_request raises on a modern-locked session" do + run_transport_session([modern_ping_request(id: 1)]) + + error = assert_raises(RuntimeError) do + @transport.send_request("roots/list") + end + assert_match(/modern lifecycle/, error.message) + end + + private + + def initialize_request(id:, protocol_version: "2025-11-25") + { + jsonrpc: "2.0", + method: "initialize", + id: id, + params: initialize_params( + protocolVersion: protocol_version, + clientInfo: { name: "legacy_client", version: "1.0" }, + ), + } + end + + def modern_ping_request(id:, version: "2026-07-28") + { + jsonrpc: "2.0", + method: "ping", + id: id, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + end + + # Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order. + def run_transport_session(frames) + input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n") + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + thread = Thread.new { @transport.open } + sleep(0.1) + @transport.close + thread.join + ensure + $stdin = original_stdin + $stdout = original_stdout + end + + output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) } + end + + def session_era + @transport.instance_variable_get(:@session).era + end end end end From e591cbb804a1bdb88d0eefcb74940557a038d3e7 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 12:24:33 +0900 Subject: [PATCH 173/247] Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575 ## Motivation and Context Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first, like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside every supported list enters a new sessionless modern path, while requests without the header (or with a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }` instead of a generic invalid-request error. Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model, and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates by request shape: an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request, since Rack 3 inputs need not be rewindable. The modern path (`handle_modern`) is a single POST/JSON exchange: - GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405. - It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`, and rejects requests carrying one with HTTP 400. - Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path. - Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent. `Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII. - HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400, `-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else including internal errors stays 200. - Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`, whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`. The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple, which would previously fall through the legacy path via the header default. Session-bound under a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because the session already negotiated the legacy lifecycle (mirroring the stdio era lock). With the header missing or naming a stable-only version, it violates the header/body match requirement and is rejected as a header mismatch (`-32020`). Intentional behavior changes for previously-erroneous requests, all following from header-primary routing: an unknown header version now yields `-32022` with data instead of the legacy `-32600` message (`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version, or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed on the legacy path. Existing tests were updated to codify these. Refs #389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover: the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list for unknown header versions, `-32020` for header/body version, `Mcp-Method`, and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection, 405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`), `-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header. Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path, a session-bound POST carrying the modern envelope is rejected as `-32600`, and an oversized modern POST returns 413. ## Breaking Changes None for conforming clients: requests without the header or with a stable-only version are byte-identical to before, and legacy clients negotiating 2026-07-28 over the classic handshake connect unchanged. Requests that were already rejected change error shape (see intentional behavior changes above) to the forms the 2026-07-28 spec mandates. --- .../transports/streamable_http_transport.rb | 252 ++++++++++++- .../streamable_http_transport_test.rb | 338 ++++++++++++++++-- 2 files changed, 560 insertions(+), 30 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 3c87dc9..4c19eac 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -149,6 +149,21 @@ module MCP # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze + # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575). + NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze + + # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404 + # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors, + # stays 200, matching the Python SDK's status ladder. + MODERN_BAD_REQUEST_CODES = [ + ErrorCodes::HEADER_MISMATCH, + ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, + ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + JsonRpcHandler::ErrorCode::PARSE_ERROR, + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ].freeze + # Rack app interface. This transport can be mounted as a Rack app. def call(env) handle_request(Rack::Request.new(env)) @@ -158,6 +173,41 @@ module MCP rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error + # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside + # every supported list routes to the sessionless modern path, so an unknown future version receives + # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error. + # Requests without the header, or with a stable-only version, take the existing paths untouched. + # An empty header value is malformed rather than a version claim, so it stays on the legacy path + # and fails legacy header validation as before. + # + # 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version + # alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session + # (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose + # body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is + # sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests + # that get the modern path's error shape). + header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] + if header_version && !header_version.empty? && !stable_only_version?(header_version) + unless MCP::Configuration.modern_protocol_version?(header_version) + return handle_modern(request, header_version) + end + + if extract_session_id(request).nil? + return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST" + + # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here, + # bounded, and hands the string to whichever path serves the request. + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + + unless legacy_handshake_body?(body_string) + return handle_modern(request, header_version, body_string: body_string) + end + + return handle_post(request, body_string: body_string) + end + end + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -457,7 +507,158 @@ module MCP stream.flush end - def handle_post(request) + # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575): + # a single POST/JSON exchange with no session. The modern path never consults + # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts + # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`) + # and DELETE (session termination) have no modern meaning. + def handle_modern(request, header_version, body_string: nil) + return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST" + + accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE) + return accept_error if accept_error + + content_type_error = validate_content_type(request) + return content_type_error if content_type_error + + if body_string.nil? + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + end + + begin + body = parse_request_body(body_string) + rescue InvalidJsonError + return invalid_json_response + end + + unless body.is_a?(Hash) + return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") + end + + # The version check precedes everything else that depends on request content, + # so a client probing with an unknown future version always receives the `-32022` signal + # (with the supported list to select from) rather than an incidental error. + unless MCP::Configuration.modern_protocol_version?(header_version) + return json_rpc_error_response( + status: 400, + code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + message: "Unsupported protocol version", + data: { + supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, + requested: header_version, + }, + id: body[:id], + ) + end + + if extract_session_id(request) + return json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle", + id: body[:id], + ) + end + + mismatch_error = validate_modern_headers(request, body, header_version) + return mismatch_error if mismatch_error + + response = @server.handle(body, session: modern_session) + + # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path. + return handle_accepted if response.nil? + + [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]] + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { request: body_string }) + json_rpc_error_response( + status: 500, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal server error", + ) + end + + # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header + # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match + # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks + # converge on enforcement. + def validate_modern_headers(request, body, header_version) + params = body[:params] + meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil + if meta_version && meta_version != header_version + return header_mismatch_response( + "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'", + body[:id], + ) + end + + method_header = request.env["HTTP_MCP_METHOD"] + if method_header && method_header != body[:method] + return header_mismatch_response( + "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'", + body[:id], + ) + end + + name_header = request.env["HTTP_MCP_NAME"] + if name_header && NAME_BEARING_METHODS.include?(body[:method]) + body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil + decoded_name = decode_header_value(name_header) + if body_name && decoded_name != body_name + return header_mismatch_response( + "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", + body[:id], + ) + end + end + + nil + end + + def header_mismatch_response(message, id) + json_rpc_error_response( + status: 400, + code: ErrorCodes::HEADER_MISMATCH, + message: "Header mismatch: #{message}", + id: id, + ) + end + + # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64??=` decodes to + # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport + # requires faraday, which servers do not depend on. + def decode_header_value(value) + match = value.match(/\A=\?base64\?(.*)\?=\z/m) + return value unless match + + match[1].unpack1("m0").force_encoding(Encoding::UTF_8) + rescue ArgumentError + value + end + + # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to + # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing + # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions + # via the `session_id.nil?` branch. + def modern_session + ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern) + end + + def modern_http_status(response) + error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil + if error_code.nil? + 200 + elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND + 404 + elsif MODERN_BAD_REQUEST_CODES.include?(error_code) + 400 + else + 200 + end + end + + def handle_post(request, body_string: nil) required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE accept_error = validate_accept_header(request, required_types) return accept_error if accept_error @@ -465,8 +666,10 @@ module MCP content_type_error = validate_content_type(request) return content_type_error if content_type_error - body_string = read_bounded_body(request) - return payload_too_large_response if body_string.nil? + if body_string.nil? + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + end session_id = extract_session_id(request) @@ -483,6 +686,28 @@ module MCP return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") end + # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs, + # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only. + # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session + # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras + # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version, + # which violates the header/body match requirement and would fall through the legacy path via + # the header default; reject that as a header mismatch. + if RequestEnvelope.modern?(body[:params]) + header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] + if header_version && MCP::Configuration.modern_protocol_version?(header_version) + return invalid_request_response( + "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", + request_id: body[:id], + ) + end + + return header_mismatch_response( + "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope", + body[:id], + ) + end + # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # This matches the TypeScript and Python SDKs. @@ -748,6 +973,21 @@ module MCP body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER end + # A version negotiable only through the legacy handshake, with no modern meaning. + # Dual-era versions (2026-07-28) appear in both lists and need further disambiguation. + def stable_only_version?(version) + MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) && + !MCP::Configuration.modern_protocol_version?(version) + end + + # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body is legacy-distinctive. + # Unparsable or non-object bodies go to the modern path, whose error responses cover them. + def legacy_handshake_body?(body_string) + initialize_request?(parse_request_body(body_string)) + rescue InvalidJsonError + false + end + def validate_protocol_version_header(request) header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) @@ -760,8 +1000,10 @@ module MCP ) end - def json_rpc_error_response(status:, code:, message:) - body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } + def json_rpc_error_response(status:, code:, message:, data: nil, id: nil) + error = { code: code, message: message } + error[:data] = data if data + body = { jsonrpc: "2.0", id: id, error: error } [status, { "content-type" => "application/json" }, [body.to_json]] end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 4912ceb..b7ba329 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -369,7 +369,7 @@ module MCP response = @transport.handle_request(request) assert_equal 202, response[0] - refute response[1].key?("Mcp-Session-Id"), "no session id should leak from an id-less init" + refute response[1].key?("mcp-session-id"), "no session id should leak from an id-less init" assert_equal [], response[2] assert_equal({}, @transport.instance_variable_get(:@sessions)) end @@ -661,7 +661,7 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") - refute response[1].key?("Mcp-Session-Id") + refute response[1].key?("mcp-session-id") end test "allows server/discover POST without session ID in stateless mode" do @@ -1840,7 +1840,26 @@ module MCP assert_equal "text/event-stream", response[1]["content-type"] end - test "POST initialize request ignores MCP-Protocol-Version header" do + test "POST initialize request ignores MCP-Protocol-Version header among legacy versions" do + # Per SEP-2575 header-primary routing, stable-only header values stay on the legacy path + # where `initialize` treats the body `params.protocolVersion` as authoritative. + # An unknown header value routes to the modern path (see the test below), and under + # the dual-era 2026-07-28 header a sessionless `initialize` stays legacy as well. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => "2024-11-05", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST initialize request with a non-legacy MCP-Protocol-Version header is rejected as modern traffic" do request = create_rack_request( "POST", "/", @@ -1852,7 +1871,10 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 200, response[0] + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32022, body.dig("error", "code")) + assert_equal "1900-01-01", body.dig("error", "data", "requested") end test "POST initialize request negotiates body protocolVersion when header is an older supported version" do @@ -1932,12 +1954,13 @@ module MCP assert_equal 400, response[0] assert_equal({ "content-type" => "application/json" }, response[1]) + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic and + # receives `-32022` with the supported list instead of the legacy `-32600` message. body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] - assert_includes body["error"]["message"], "1999-01-01" - assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION + assert_equal(-32022, body["error"]["code"]) + assert_equal "1999-01-01", body.dig("error", "data", "requested") + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") end test "POST request with malformed MCP-Protocol-Version returns 400" do @@ -1965,7 +1988,8 @@ module MCP assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_includes body["error"]["message"], "not-a-version" + assert_equal(-32022, body["error"]["code"]) + assert_equal "not-a-version", body.dig("error", "data", "requested") end test "POST request with supported MCP-Protocol-Version succeeds" do @@ -2056,10 +2080,10 @@ module MCP ) response = @transport.send(:validate_protocol_version_header, request) - assert_equal 400, response[0] + assert_equal(400, response[0]) body = JSON.parse(response[2][0]) - assert_includes body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION + assert_includes(body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION) ensure MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions) @@ -2121,7 +2145,9 @@ module MCP assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] end - test "GET request with unsupported MCP-Protocol-Version returns 400" do + test "GET request with a non-legacy MCP-Protocol-Version returns 405" do + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, + # and the modern lifecycle removed the GET listening stream. init_request = create_rack_request( "POST", "/", @@ -2141,10 +2167,7 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 400, response[0] - - body = JSON.parse(response[2][0]) - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal 405, response[0] end test "GET request without MCP-Protocol-Version header succeeds" do @@ -2167,7 +2190,9 @@ module MCP assert_equal 200, response[0] end - test "DELETE request with unsupported MCP-Protocol-Version returns 400" do + test "DELETE request with a non-legacy MCP-Protocol-Version returns 405" do + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, + # and the modern lifecycle has no DELETE (there is no session to terminate). init_request = create_rack_request( "POST", "/", @@ -2187,13 +2212,10 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 400, response[0] - - body = JSON.parse(response[2][0]) - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal 405, response[0] end - test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do + test "DELETE request with a non-legacy MCP-Protocol-Version returns 405 in stateless mode" do stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) request = create_rack_request( @@ -2203,10 +2225,11 @@ module MCP ) response = stateless_transport.handle_request(request) - assert_equal 400, response[0] + assert_equal 405, response[0] end - test "DELETE request validates session before MCP-Protocol-Version" do + test "DELETE request with an unknown session and a non-legacy MCP-Protocol-Version returns 405" do + # Header-primary routing (SEP-2575) decides the era before any session validation. request = create_rack_request( "DELETE", "/", @@ -2217,7 +2240,7 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 404, response[0] + assert_equal 405, response[0] end test "stateless mode allows requests without session IDs, responding with no session ID" do @@ -5268,6 +5291,245 @@ module MCP transport.close end + test "modern POST serves a single sessionless JSON exchange" do + @server.define_tool(name: "echo_tool") do + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "echo_tool", arguments: {} }), + headers: { "HTTP_MCP_METHOD" => "tools/call", "HTTP_MCP_NAME" => "echo_tool" }, + )) + + assert_equal 200, response[0] + refute response[1].key?("mcp-session-id") + body = JSON.parse(response[2][0]) + assert_equal "ok", body.dig("result", "content", 0, "text") + end + + test "modern POST with an unsupported header version returns 400 with -32022 and the supported list" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}, version: "2027-01-01"), + version: "2027-01-01", + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32022, body.dig("error", "code")) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") + assert_equal "2027-01-01", body.dig("error", "data", "requested") + end + + test "modern POST with a header/body version mismatch returns 400 with -32020" do + # Header names the supported modern version; the body envelope claims another. + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}, version: "2027-01-01"), + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST with an Mcp-Method header mismatch returns 400 with -32020" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + headers: { "HTTP_MCP_METHOD" => "tools/call" }, + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do + # `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives + # wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`. + uri = "file:///日本語.txt" + encoded = "=?base64?#{[uri].pack("m0")}?=" + + matched = @transport.handle_request(modern_rack_request( + modern_body("resources/read", { uri: uri }), + headers: { "HTTP_MCP_NAME" => encoded }, + )) + mismatched = @transport.handle_request(modern_rack_request( + modern_body("resources/read", { uri: "file:///other.txt" }), + headers: { "HTTP_MCP_NAME" => encoded }, + )) + + assert_equal 200, matched[0] + assert_equal 400, mismatched[0] + assert_equal(-32020, JSON.parse(mismatched[2][0]).dig("error", "code")) + end + + test "modern POST rejects an Mcp-Session-Id header" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + headers: { "HTTP_MCP_SESSION_ID" => "some-session" }, + )) + + assert_equal 400, response[0] + assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern GET and DELETE are not allowed" do + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + ) + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + ) + + assert_equal 405, @transport.handle_request(get_request)[0] + assert_equal 405, @transport.handle_request(delete_request)[0] + end + + test "modern POST maps an unknown method to 404 with -32601" do + response = @transport.handle_request(modern_rack_request(modern_body("no/such_method", {}))) + + assert_equal 404, response[0] + assert_equal(-32601, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST requires the _meta envelope" do + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "ping", id: 1 }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST maps a missing client capability to 400 with -32021" do + @server.define_tool(name: "guarded_tool") do |server_context:| + server_context.require_client_capability!(:elicitation, :form) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "guarded_tool", arguments: {} }), + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32021, body.dig("error", "code")) + assert_equal({ "elicitation" => { "form" => {} } }, body.dig("error", "data", "requiredCapabilities")) + end + + test "modern POST serves server/discover without an envelope" do + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, + )) + + assert_equal 200, response[0] + refute response[1].key?("mcp-session-id") + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + end + + test "legacy POST rejects a modern envelope body without the modern header as -32020" do + # Without header-primary routing evidence, the body-level triple would silently fall through + # the legacy path via the header default. + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + modern_body("ping", {}), + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28" do + # The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is + # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + refute_nil response[1]["mcp-session-id"] + assert_equal "2026-07-28", JSON.parse(response[2][0]).dig("result", "protocolVersion") + end + + test "session-bound POST with the dual-era header stays on the legacy path" do + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + )) + + assert_equal 200, response[0] + end + + test "session-bound DELETE with the dual-era header terminates the legacy session" do + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + )) + + assert_equal 200, response[0] + end + + test "session-bound POST with the dual-era header carrying the modern envelope is rejected as -32600" do + # The session already negotiated the legacy lifecycle, and a connection can never change eras + # (mirroring the stdio era lock). + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + modern_body("ping", {}), + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32600, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "legacy lifecycle" + end + + test "modern POST larger than max_request_bytes returns 413" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 64) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + modern_body("ping", { filler: "A" * 200 }), + ) + + response = transport.handle_request(request) + assert_equal(413, response[0]) + ensure + transport.close + end + private def initialize_test_session(id: "init") @@ -5336,6 +5598,32 @@ module MCP Rack::Request.new(env) end + + # Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header. + def modern_rack_request(body, version: "2026-07-28", headers: {}) + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }.merge(headers), + body, + ) + end + + # Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope. + def modern_body(method, params, version: "2026-07-28", capabilities: {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + ), + }.to_json + end end end end From 6a43d3d6fb33f550831fbd293d341127b2ecd840 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sun, 14 Jun 2026 11:45:38 +0200 Subject: [PATCH 174/247] feat: deprecate roots sampling and logging Warn when Roots, Sampling, or Logging APIs are used with MCP protocol version 2026-07-28 or newer. Add a shared protocol deprecation helper, YARD deprecation notes, and regression tests covering warnings at and after the deprecation threshold and no warnings for older or never-established protocol versions. Closes https://github.com/modelcontextprotocol/ruby-sdk/issues/390 Co-authored-by: Qlaw --- lib/mcp.rb | 1 + lib/mcp/client/http.rb | 3 + lib/mcp/client/stdio.rb | 3 + lib/mcp/configuration.rb | 1 + lib/mcp/protocol_deprecations.rb | 52 ++++++++++++++ lib/mcp/server.rb | 32 ++++++++- lib/mcp/server_session.rb | 10 ++- test/mcp/client/http_test.rb | 40 +++++++++++ test/mcp/client/stdio_test.rb | 34 +++++++++ test/mcp/protocol_deprecations_test.rb | 14 ++++ test/mcp/server_notification_test.rb | 96 ++++++++++++++++++++++++++ test/mcp/server_roots_test.rb | 73 ++++++++++++++++++++ test/mcp/server_sampling_test.rb | 43 ++++++++++++ test/mcp/server_test.rb | 91 ++++++++++++++++++++++++ test/test_helper.rb | 18 +++++ 15 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 lib/mcp/protocol_deprecations.rb create mode 100644 test/mcp/protocol_deprecations_test.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 497919d..0201827 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -2,6 +2,7 @@ require_relative "json_rpc_handler" require_relative "mcp/configuration" +require_relative "mcp/protocol_deprecations" require_relative "mcp/string_utils" require_relative "mcp/transport" require_relative "mcp/version" diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 112fe3e..8a572cf 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -4,6 +4,7 @@ require "securerandom" require_relative "../../json_rpc_handler" require_relative "../configuration" require_relative "../methods" +require_relative "../protocol_deprecations" require_relative "../version" module MCP @@ -334,6 +335,8 @@ module MCP ) end + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) + begin send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 5bcf651..747687b 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -7,6 +7,7 @@ require "timeout" require_relative "../../json_rpc_handler" require_relative "../configuration" require_relative "../methods" +require_relative "../protocol_deprecations" require_relative "../version" module MCP @@ -128,6 +129,8 @@ module MCP ) end + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) + begin notification = { jsonrpc: JsonRpcHandler::Version::V2_0, diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index ed6d430..390643b 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -3,6 +3,7 @@ module MCP class Configuration LATEST_STABLE_PROTOCOL_VERSION = "2026-07-28" + ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION = "2026-07-28" SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ LATEST_STABLE_PROTOCOL_VERSION, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", ].freeze diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb new file mode 100644 index 0000000..ee56161 --- /dev/null +++ b/lib/mcp/protocol_deprecations.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "configuration" + +module MCP + module ProtocolDeprecations + extend self + + ROOTS_MESSAGE = + "MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version " \ + "2026-07-28 (SEP-2577). Use tool parameters, resource URIs, server configuration, or environment " \ + "variables instead." + SAMPLING_MESSAGE = + "MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version 2026-07-28 (SEP-2577). " \ + "Use direct LLM provider APIs instead." + LOGGING_MESSAGE = + "MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version " \ + "2026-07-28 (SEP-2577). Use stderr or OpenTelemetry instead." + + MESSAGES = { + roots: ROOTS_MESSAGE, + sampling: SAMPLING_MESSAGE, + logging: LOGGING_MESSAGE, + }.freeze + + def deprecated_roots_sampling_logging?(protocol_version) + return false unless protocol_version + + protocol_version >= Configuration::ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION + end + + def warn_for(feature, protocol_version:, uplevel: 1) + return unless deprecated_roots_sampling_logging?(protocol_version) + + Kernel.warn(MESSAGES.fetch(feature), uplevel: uplevel) + end + + def warn_for_client_capabilities(capabilities, protocol_version:, uplevel: 1) + return unless deprecated_roots_sampling_logging?(protocol_version) + return unless capabilities + + warn_for(:roots, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :roots) + warn_for(:sampling, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :sampling) + end + + private + + def capability?(capabilities, key) + capabilities.key?(key) || capabilities.key?(key.to_s) + end + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 910c8d8..8413b8c 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -7,6 +7,7 @@ require_relative "instrumentation" require_relative "methods" require_relative "logging_message_notification" require_relative "progress" +require_relative "protocol_deprecations" require_relative "server_context" require_relative "server/capabilities" require_relative "server/pagination" @@ -184,6 +185,7 @@ module MCP self.cache_scope = cache_scope @configuration = MCP.configuration.merge(configuration) @client = nil + @client_protocol_version = nil validate! @@ -354,6 +356,8 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil) + warn_if_deprecated_protocol_feature(:logging, uplevel: 1) + return unless @transport return unless logging_message_notification&.should_notify?(level) @@ -374,6 +378,8 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def roots_list_changed_handler(&block) + warn_if_deprecated_protocol_feature(:roots, uplevel: 1) + @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end @@ -538,6 +544,13 @@ module MCP return ->(params) { handle_cancelled_notification(params, session: session) } end + case method + when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED + warn_if_deprecated_protocol_feature(:roots, session: session, uplevel: 2) + when Methods::LOGGING_SET_LEVEL + warn_if_deprecated_protocol_feature(:logging, session: session, uplevel: 2) + end + handler = @handlers[method] unless handler instrument_call("unsupported_method", server_context: { request: request }) do @@ -706,7 +719,11 @@ module MCP response_instructions = nil end - session&.mark_initialized! + if session + session.mark_initialized!(protocol_version: negotiated_version) + else + @client_protocol_version = negotiated_version + end { protocolVersion: negotiated_version, @@ -773,6 +790,19 @@ module MCP {} end + def warn_if_deprecated_protocol_feature(feature, session: nil, uplevel: 1) + protocol_version = effective_deprecation_protocol_version(session) + MCP::ProtocolDeprecations.warn_for(feature, protocol_version: protocol_version, uplevel: uplevel) + end + + def effective_deprecation_protocol_version(session) + session&.protocol_version || @client_protocol_version || explicit_protocol_version + end + + def explicit_protocol_version + configuration.protocol_version if configuration.protocol_version? + end + def list_tools(request) page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 7cbf2fb..dfb765e 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -9,7 +9,7 @@ module MCP class ServerSession ERAS = [:legacy, :modern].freeze - attr_reader :session_id, :client, :logging_message_notification + attr_reader :session_id, :client, :logging_message_notification, :protocol_version # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds, # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions @@ -26,6 +26,7 @@ module MCP @client = nil @client_capabilities = nil @logging_message_notification = nil + @protocol_version = nil @in_flight = {} @in_flight_mutex = Mutex.new @initialized = false @@ -39,8 +40,9 @@ module MCP # Called by `Server#init` after a successful `initialize` response, so subsequent # `initialize` requests on the same session can be rejected per MCP spec # (the initialization phase MUST be the first interaction). - def mark_initialized! + def mark_initialized!(protocol_version: nil) @initialized = true + @protocol_version = protocol_version # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575), # so it also locks the connection era. @era ||= :legacy @@ -130,6 +132,7 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def list_roots(related_request_id: nil) + @server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:roots) @@ -156,6 +159,7 @@ module MCP # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider # APIs instead. def create_sampling_message(related_request_id: nil, **kwargs) + @server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) params = @server.build_sampling_params(client_capabilities, **kwargs) @@ -246,6 +250,8 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil, related_request_id: nil) + @server.send(:warn_if_deprecated_protocol_feature, :logging, session: self, uplevel: 2) + effective_logging = @logging_message_notification || @server.logging_message_notification return unless effective_logging&.should_notify?(level) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 85f93fa..3fdb527 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -11,6 +11,8 @@ require "mcp/client" module MCP class Client class HTTPTest < Minitest::Test + include DeprecationWarningTestHelper + def test_raises_load_error_when_faraday_not_available client = HTTP.new(url: url) @@ -1705,6 +1707,44 @@ module MCP assert_requested(notification_stub) end + def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2026-07-28" } }.to_json, + ) + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + assert_no_deprecation_warning do + client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_requested(init_stub) + assert_requested(notification_stub) + end + def test_connect_is_idempotent init_stub = stub_initialize notification_stub = stub_notification diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 1995222..158fa93 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -9,6 +9,8 @@ require "mcp/client/tool" module MCP class Client class StdioTest < Minitest::Test + include DeprecationWarningTestHelper + def test_send_request_raises_when_connect_not_called Open3.expects(:popen3).never @@ -816,6 +818,38 @@ module MCP stdout_write.close end + def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2026-07-28" }, + )) + stdout_write.flush + stdin_read.gets + end + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + end + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_connect_raises_on_jsonrpc_error_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/protocol_deprecations_test.rb b/test/mcp/protocol_deprecations_test.rb new file mode 100644 index 0000000..d7fbbd9 --- /dev/null +++ b/test/mcp/protocol_deprecations_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ProtocolDeprecationsTest < ActiveSupport::TestCase + test "roots, sampling, and logging are deprecated in 2026-07-28 and later" do + refute ProtocolDeprecations.deprecated_roots_sampling_logging?(nil) + refute ProtocolDeprecations.deprecated_roots_sampling_logging?("2025-11-25") + assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2026-07-28") + assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2027-01-01") + end + end +end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 74a89cc..55ece8a 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -5,6 +5,7 @@ require "test_helper" module MCP class ServerNotificationTest < ActiveSupport::TestCase include InstrumentationTestHelper + include DeprecationWarningTestHelper class MockTransport < Transport attr_reader :notifications @@ -74,6 +75,101 @@ module MCP assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error" }, @mock_transport.notifications.first[:params]) end + test "#notify_log_message does not warn when no protocol version has been established" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_no_deprecation_warning do + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] + end + + test "#notify_log_message warns when configured protocol version is 2026-07-28" do + server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: Configuration.new(protocol_version: "2026-07-28"), + ) + mock_transport = MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + + assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] + end + + test "#notify_log_message warns when configured protocol version is 2026-07-28 without transport" do + server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: Configuration.new(protocol_version: "2026-07-28"), + ) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + end + + test "#notify_log_message warns when configured protocol version is 2026-07-28 below configured level" do + server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: Configuration.new(protocol_version: "2026-07-28"), + ) + mock_transport = MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + server.notify_log_message(data: { message: "test" }, level: "info") + end + + assert_empty mock_transport.notifications + end + + test "#notify_log_message does not warn when configured protocol version is older" do + server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: Configuration.new(protocol_version: "2025-11-25"), + ) + MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_no_deprecation_warning do + server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + end + + test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + session = ServerSession.new(server: server, transport: mock_transport) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + session: session, + ) + session.configure_logging(MCP::LoggingMessageNotification.new(level: "error")) + + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + session.notify_log_message(data: { message: "test" }, level: "info") + end + + assert_empty mock_transport.notifications + end + test "#notify_log_message sends notification with logger through transport" do @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") @server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger") diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index f8f31f9..283fbdd 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -4,6 +4,8 @@ require "test_helper" module MCP class ServerRootsTest < ActiveSupport::TestCase + include DeprecationWarningTestHelper + class MockTransport < Transport attr_reader :requests @@ -62,6 +64,36 @@ module MCP assert callback_called end + test "notifications/roots/list_changed warns when negotiated protocol version is 2026-07-28" do + server = Server.new(name: "test", version: "1.0") + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + end + end + + test "notifications/roots/list_changed does not warn when negotiated protocol version is older" do + assert_no_deprecation_warning do + @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + end + end + test "notifications/roots/list_changed is handled as no-op by default" do result = @server.handle({ jsonrpc: "2.0", @@ -144,6 +176,21 @@ module MCP assert_silent { session.list_roots(related_request_id: "req-1") } end + test "ServerSession#list_roots warns when negotiated protocol version is 2026-07-28 and client lacks roots" do + session = ServerSession.new(server: @server, transport: @mock_transport) + session.store_client_info(client: { name: "test-client" }, capabilities: {}) + session.mark_initialized!(protocol_version: "2026-07-28") + + error = nil + assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + error = assert_raises(RuntimeError) do + session.list_roots(related_request_id: "req-1") + end + end + + assert_equal("Client does not support roots.", error.message) + end + test "ServerSession#client_capabilities falls back to server global capabilities" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) @@ -219,5 +266,31 @@ module MCP end assert_equal("No active stream for roots/list request.", error.message) end + + test "ServerSession#list_roots warns when session negotiated protocol version is 2026-07-28" do + server = Server.new(name: "test", version: "1.0") + transport = MockTransport.new(server) + + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: { roots: {} }, + clientInfo: { name: "http-client", version: "1.0" }, + }, + }, + session: session, + ) + + assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + session.list_roots(related_request_id: "req-1") + end + + assert_equal Methods::ROOTS_LIST, transport.requests.first[:method] + end end end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index 9c6985b..b2c84f5 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -5,6 +5,7 @@ require "test_helper" module MCP class ServerSamplingTest < ActiveSupport::TestCase include InstrumentationTestHelper + include DeprecationWarningTestHelper class MockTransport < Transport attr_reader :requests @@ -63,6 +64,30 @@ module MCP assert_equal "Response from LLM", result[:content][:text] end + test "create_sampling_message warns when session negotiated protocol version is 2026-07-28" do + @session.mark_initialized!(protocol_version: "2026-07-28") + + assert_deprecation_warning(/MCP Sampling .*2026-07-28/) 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 does not warn when session negotiated protocol version is older" do + @session.mark_initialized!(protocol_version: "2025-11-25") + + assert_no_deprecation_warning 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 sends all optional params" do @session.create_sampling_message( related_request_id: "req-1", @@ -101,6 +126,24 @@ module MCP assert_equal("Client does not support sampling.", error.message) end + test "create_sampling_message warns when negotiated protocol version is 2026-07-28 and client lacks sampling" do + @session.store_client_info(client: { name: "test-client" }, capabilities: {}) + @session.mark_initialized!(protocol_version: "2026-07-28") + + error = nil + assert_deprecation_warning(/MCP Sampling .*2026-07-28/) 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, + ) + end + end + + assert_equal("Client does not support sampling.", error.message) + 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( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index ff7aba5..108e652 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -6,6 +6,7 @@ module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper include InitializeParamsTestHelper + include DeprecationWarningTestHelper setup do @tool = Tool.define( name: "test_tool", @@ -1378,6 +1379,67 @@ module MCP refute response.key?(:error) end + test "#configure_logging_level warns when negotiated protocol version is 2026-07-28" do + server = Server.new(tools: [TestTool]) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + ) + + response = nil + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + response = server.handle( + { + jsonrpc: "2.0", + id: 2, + method: "logging/setLevel", + params: { + level: "info", + }, + }, + ) + end + + assert_empty response[:result] + end + + test "#configure_logging_level does not warn when negotiated protocol version is older" do + server = Server.new(tools: [TestTool]) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + ) + + assert_no_deprecation_warning do + server.handle( + { + jsonrpc: "2.0", + id: 2, + method: "logging/setLevel", + params: { + level: "info", + }, + }, + ) + end + end + test "#configure_logging_level returns an error object when invalid log level is provided" do server = Server.new( tools: [TestTool], @@ -1429,6 +1491,35 @@ module MCP assert_includes response[:error][:data], "Server does not support logging" end + test "#configure_logging_level warns when configured protocol version is 2026-07-28 and server lacks logging capability" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(protocol_version: "2026-07-28"), + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true }, + }, + ) + + response = nil + assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "debug", + }, + }, + ) + end + + assert_equal(-32603, response[:error][:code]) + assert_includes response[:error][:data], "Server does not support logging" + end + test "#handle method with missing required top-level capability returns an error" do @server.capabilities = {} diff --git a/test/test_helper.rb b/test/test_helper.rb index 4de7ffd..8cd6b9e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,3 +23,21 @@ require "sorbet-runtime" if RUBY_VERSION >= "3.0" require_relative "instrumentation_test_helper" require_relative "initialize_params_test_helper" + +module DeprecationWarningTestHelper + def assert_deprecation_warning(message_pattern, &block) + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, message_pattern, &block) + ensure + $VERBOSE = original_verbose + end + + def assert_no_deprecation_warning(&block) + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, "", &block) + ensure + $VERBOSE = original_verbose + end +end From b54eebac75b67eb7b391be2bcc7b2253d8043b13 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 1 May 2026 02:21:08 +0900 Subject: [PATCH 175/247] Add `MCP::Elicitation::EnumSchema` builders for SEP-1330 ## Motivation and Context MCP 2025-11-25 (SEP-1330) replaces the ad-hoc `enumNames` convention with four standards-based enum schema variants for elicitation requests: titled/untitled single-select and titled/untitled multi-select. The TypeScript SDK ships typed Zod definitions for each shape (`packages/core/src/types/schemas.ts`), and the Python SDK relies on hand-built schemas with the same shapes in its conformance fixtures. Adds builder class methods on `MCP::Elicitation::EnumSchema` that produce the canonical JSON-Schema shapes for each variant, plus a `legacy_titled` builder retained for backward compatibility with clients that have not yet migrated. Each constructor returns an `EnumSchema` instance whose `to_h` produces the property-value Hash to pass through `requested_schema:` on `ServerSession#create_form_elicitation`. Each builder maps one-to-one to the spec's schema type names in `schema/2025-11-25/schema.ts`, which the TypeScript and Python SDKs also carry verbatim in their type layers: | Spec type (schema.ts) | Builder | |---------------------------------------------------|-------------------------------------------------| | `UntitledSingleSelectEnumSchema` | `untitled_single_select` | | `TitledSingleSelectEnumSchema` | `titled_single_select` | | `UntitledMultiSelectEnumSchema` | `untitled_multi_select` | | `TitledMultiSelectEnumSchema` | `titled_multi_select` | | `LegacyTitledEnumSchema` (deprecated in the spec) | `legacy_titled` | | `EnumSchema` (the union of all five) | the `MCP::Elicitation::EnumSchema` class itself | These shapes stay relevant on the stateless 2026-07-28 lifecycle: elicitation is not deprecated by SEP-2577 and reaches modern clients as `elicitation/create` requests embedded in SEP-2322 `input_required` results, whose `requestedSchema` uses the same variants. The README documents that pairing; the example references `MCP::Server::InputRequiredResult`, so this change should merge after the SEP-2322 server-side PR. The conformance server now builds the enum schemas of its `test_elicitation_sep1330_enums` tool (and the `status` enum of the SEP-1034 defaults tool) through these builders instead of hand-written hashes, so the conformance run exercises the exact schemas the SDK produces for users. ## How Has This Been Tested? `test/mcp/elicitation/enum_schema_test.rb` covers each of the five shapes with happy-path emission, `default` propagation, `title` / `description` propagation, and the four ArgumentError cases (empty values, missing `:value`, missing `:title`, mismatched `value_titles` length). Two additional regression tests assert that empty-but-non-nil defaults survive `to_h`: - `EnumSchema.untitled_single_select(default: "")` keeps the empty string in the emitted Hash - `EnumSchema.untitled_multi_select(default: [])` keeps the empty array in the emitted Hash `bundle exec rake` (tests, RuboCop, and conformance baseline) passes; the `elicitation-sep1330-enums` and `elicitation-sep1034-defaults` conformance scenarios pass with the builder-produced schemas, confirming they emit the same wire shapes as the previous hand-written hashes. ## Breaking Changes None. The change is purely additive: new module `MCP::Elicitation::EnumSchema` with new class methods. Existing callers of `ServerSession#create_form_elicitation` that pass raw hashes continue to work unchanged. --- README.md | 58 +++++++++ conformance/server.rb | 62 +++++----- lib/mcp.rb | 1 + lib/mcp/elicitation.rb | 10 ++ lib/mcp/elicitation/enum_schema.rb | 121 +++++++++++++++++++ test/mcp/elicitation/enum_schema_test.rb | 143 +++++++++++++++++++++++ 6 files changed, 361 insertions(+), 34 deletions(-) create mode 100644 lib/mcp/elicitation.rb create mode 100644 lib/mcp/elicitation/enum_schema.rb create mode 100644 test/mcp/elicitation/enum_schema_test.rb diff --git a/README.md b/README.md index 000e6e2..adfa651 100644 --- a/README.md +++ b/README.md @@ -1970,6 +1970,64 @@ server.define_tool(name: "configure_deploy", description: "Configure a deploymen end ``` +#### Enum Schemas + +For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per +[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) instead of building +the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select, +plus the legacy `enumNames` form retained for backward compatibility: + +```ruby +size_schema = MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "s", title: "Small" }, + { value: "m", title: "Medium" }, + { value: "l", title: "Large" }, + ], + default: "m", +) + +tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["urgent", "billing", "feedback"], +) + +result = server_context.create_form_elicitation( + message: "Tell us about your order", + requested_schema: { + type: "object", + properties: { + size: size_schema.to_h, + tags: tags_schema.to_h, + }, + required: ["size"], + }, +) +``` + +The available builders are `untitled_single_select`, `titled_single_select`, `untitled_multi_select`, `titled_multi_select`, +and `legacy_titled`. Each accepts optional `default:`, `title:`, and `description:`. + +The same builders produce the `requestedSchema` of an `elicitation/create` request embedded in a SEP-2322 `input_required` result, +which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle: + +```ruby +MCP::Server::InputRequiredResult.new( + input_requests: { + "size" => { + method: "elicitation/create", + params: { + message: "Pick a size", + requestedSchema: { + type: "object", + properties: { size: size_schema.to_h }, + required: ["size"], + }, + }, + }, + }, +) +``` + #### URL Mode URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: diff --git a/conformance/server.rb b/conformance/server.rb index 7b697d8..eb6a2e3 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -219,7 +219,9 @@ module Conformance name: { type: "string", default: "John Doe" }, age: { type: "integer", default: 30 }, score: { type: "number", default: 95.5 }, - status: { type: "string", enum: ["active", "inactive", "pending"], default: "active" }, + status: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["active", "inactive", "pending"], default: "active", + ).to_h, verified: { type: "boolean", default: true }, }, }, @@ -235,45 +237,37 @@ module Conformance class << self def call(server_context:, **_args) + # Built with the SEP-1330 builders so the conformance run exercises the same schemas + # the SDK produces for users. result = server_context.create_form_elicitation( message: "Please select options", requested_schema: { type: "object", properties: { - untitledSingle: { - type: "string", - enum: ["option1", "option2", "option3"], - }, - titledSingle: { - type: "string", - oneOf: [ - { const: "value1", title: "First Option" }, - { const: "value2", title: "Second Option" }, - { const: "value3", title: "Third Option" }, + untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledSingle: MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "value1", title: "First Option" }, + { value: "value2", title: "Second Option" }, + { value: "value3", title: "Third Option" }, ], - }, - legacyEnum: { - type: "string", - enum: ["opt1", "opt2", "opt3"], - enumNames: ["Option One", "Option Two", "Option Three"], - }, - untitledMulti: { - type: "array", - items: { - type: "string", - enum: ["option1", "option2", "option3"], - }, - }, - titledMulti: { - type: "array", - items: { - anyOf: [ - { const: "value1", title: "First Choice" }, - { const: "value2", title: "Second Choice" }, - { const: "value3", title: "Third Choice" }, - ], - }, - }, + ).to_h, + legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled( + values: ["opt1", "opt2", "opt3"], + value_titles: ["Option One", "Option Two", "Option Three"], + ).to_h, + untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select( + options: [ + { value: "value1", title: "First Choice" }, + { value: "value2", title: "Second Choice" }, + { value: "value3", title: "Third Choice" }, + ], + ).to_h, }, }, ) diff --git a/lib/mcp.rb b/lib/mcp.rb index 0201827..2213f23 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -14,6 +14,7 @@ module MCP autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" + autoload :Elicitation, "mcp/elicitation" autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" diff --git a/lib/mcp/elicitation.rb b/lib/mcp/elicitation.rb new file mode 100644 index 0000000..26186c7 --- /dev/null +++ b/lib/mcp/elicitation.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module MCP + # Builders for elicitation `requestedSchema` definitions per MCP 2025-11-25. + # Each builder returns an instance whose `to_h` produces the JSON-Schema-shaped Hash + # a server passes as a property value in `create_form_elicitation(requested_schema:)`. + module Elicitation + autoload :EnumSchema, "mcp/elicitation/enum_schema" + end +end diff --git a/lib/mcp/elicitation/enum_schema.rb b/lib/mcp/elicitation/enum_schema.rb new file mode 100644 index 0000000..191a20f --- /dev/null +++ b/lib/mcp/elicitation/enum_schema.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module MCP + module Elicitation + # Builds the four enum schema variants defined by MCP 2025-11-25 (SEP-1330) plus the legacy `enumNames` form + # retained for backward compatibility. + # + # Each class method returns an `EnumSchema` instance; call `to_h` to get the property-value Hash and + # pass it through `requested_schema` to `ServerSession#create_form_elicitation`. + class EnumSchema + class << self + # Single-select with plain string values: `{ type: "string", enum: [...] }`. + def untitled_single_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "string", + extras: { enum: values }, + default: default, + title: title, + description: description, + ) + end + + # Single-select with display titles per option: `{ type: "string", oneOf: [{ const, title }, ...] }`. + # `options` is an Array of `{ value:, title: }` hashes. + def titled_single_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + new( + type: "string", + extras: { oneOf: options.map { |o| { const: o[:value], title: o[:title] } } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with plain string values: `{ type: "array", items: { type: "string", enum: [...] } }`. + def untitled_multi_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "array", + extras: { items: { type: "string", enum: values } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with display titles per option: `{ type: "array", items: { anyOf: [{ const, title }, ...] } }`. + def titled_multi_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + items_anyof = options.map { |o| { const: o[:value], title: o[:title] } } + new( + type: "array", + extras: { items: { anyOf: items_anyof } }, + default: default, + title: title, + description: description, + ) + end + + # Legacy single-select retained for backward compatibility with clients implementing + # the pre-SEP-1330 form: `{ enum, enumNames }`. + def legacy_titled(values:, value_titles:, default: nil, title: nil, description: nil) + validate_values!(values) + unless value_titles.is_a?(Array) && value_titles.length == values.length + raise ArgumentError, "value_titles must be an Array of the same length as values" + end + + new( + type: "string", + extras: { enum: values, enumNames: value_titles }, + default: default, + title: title, + description: description, + ) + end + + private + + def validate_values!(values) + unless values.is_a?(Array) && !values.empty? + raise ArgumentError, "values must be a non-empty Array" + end + end + + def validate_titled_options!(options) + unless options.is_a?(Array) && !options.empty? + raise ArgumentError, "options must be a non-empty Array of {value:, title:} hashes" + end + + options.each do |option| + unless option.is_a?(Hash) && option.key?(:value) && option.key?(:title) + raise ArgumentError, "each option must be a Hash with :value and :title keys" + end + end + end + end + + def initialize(type:, extras:, default: nil, title: nil, description: nil) + @type = type + @extras = extras + @default = default + @title = title + @description = description + end + + def to_h + hash = { type: @type }.merge(@extras) + hash[:title] = @title if @title + hash[:description] = @description if @description + hash[:default] = @default unless @default.nil? + hash + end + end + end +end diff --git a/test/mcp/elicitation/enum_schema_test.rb b/test/mcp/elicitation/enum_schema_test.rb new file mode 100644 index 0000000..19a4246 --- /dev/null +++ b/test/mcp/elicitation/enum_schema_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + module Elicitation + class EnumSchemaTest < ActiveSupport::TestCase + test "untitled_single_select returns string + enum schema" do + schema = EnumSchema.untitled_single_select(values: ["red", "green", "blue"]) + + assert_equal({ type: "string", enum: ["red", "green", "blue"] }, schema.to_h) + end + + test "untitled_single_select preserves default value" do + schema = EnumSchema.untitled_single_select(values: ["red", "green"], default: "red") + + assert_equal "red", schema.to_h[:default] + end + + test "titled_single_select returns string + oneOf schema" do + schema = EnumSchema.titled_single_select( + options: [ + { value: "small", title: "Small" }, + { value: "large", title: "Large" }, + ], + ) + + assert_equal( + { + type: "string", + oneOf: [ + { const: "small", title: "Small" }, + { const: "large", title: "Large" }, + ], + }, + schema.to_h, + ) + end + + test "untitled_multi_select returns array + items.enum schema" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b", "c"]) + + assert_equal( + { type: "array", items: { type: "string", enum: ["a", "b", "c"] } }, + schema.to_h, + ) + end + + test "untitled_multi_select preserves default array" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: ["a"]) + + assert_equal ["a"], schema.to_h[:default] + end + + test "titled_multi_select returns array + items.anyOf schema" do + schema = EnumSchema.titled_multi_select( + options: [ + { value: "us", title: "United States" }, + { value: "jp", title: "Japan" }, + ], + ) + + assert_equal( + { + type: "array", + items: { + anyOf: [ + { const: "us", title: "United States" }, + { const: "jp", title: "Japan" }, + ], + }, + }, + schema.to_h, + ) + end + + test "legacy_titled returns enum + enumNames" do + schema = EnumSchema.legacy_titled( + values: ["s", "m", "l"], + value_titles: ["Small", "Medium", "Large"], + ) + + assert_equal( + { + type: "string", + enum: ["s", "m", "l"], + enumNames: ["Small", "Medium", "Large"], + }, + schema.to_h, + ) + end + + test "title and description are included when provided" do + schema = EnumSchema.untitled_single_select( + values: ["a"], + title: "Color", + description: "Pick a color", + ) + + assert_equal "Color", schema.to_h[:title] + assert_equal "Pick a color", schema.to_h[:description] + end + + test "raises ArgumentError when values array is empty" do + assert_raises(ArgumentError) do + EnumSchema.untitled_single_select(values: []) + end + end + + test "raises ArgumentError when titled options miss :value" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ title: "missing value" }]) + end + end + + test "raises ArgumentError when titled options miss :title" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ value: "v" }]) + end + end + + test "raises ArgumentError when legacy value_titles length differs" do + assert_raises(ArgumentError) do + EnumSchema.legacy_titled(values: ["a", "b"], value_titles: ["A"]) + end + end + + test "preserves an empty-string default value" do + # Regression for `unless @default.nil?`: empty-but-non-nil defaults must still survive `to_h` + # (the guard distinguishes nil from "absent vs supplied", not truthy vs falsey). + schema = EnumSchema.untitled_single_select(values: ["", "yes", "no"], default: "") + + assert_equal "", schema.to_h[:default] + end + + test "preserves an empty-array default for multi-select" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: []) + + assert_equal [], schema.to_h[:default] + end + end + end +end From f379e13b4733737f928fdb2f0fdf02ce29bfbecb Mon Sep 17 00:00:00 2001 From: Joshua J <159440302+jj-ivx@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:47:56 +0200 Subject: [PATCH 176/247] fix: stop leaking exception messages to clients via JSON-RPC error data Tool handler exceptions and anything reaching JsonRpcHandler's blind rescue had their #message echoed into the error data member, exposing internals to untrusted clients (CWE-209). Capability errors keep their client-facing message by re-raising through RequestHandlerError, the one channel whose message is deliberately surfaced. Co-Authored-By: Claude Fable 5 --- lib/json_rpc_handler.rb | 5 +++-- lib/mcp/server.rb | 14 ++++++++++++-- test/json_rpc_handler_test.rb | 2 -- test/mcp/server_context_test.rb | 2 +- test/mcp/server_test.rb | 14 +++++++------- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 0b008fa..6cbce1c 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -135,11 +135,12 @@ module JsonRpcHandler success_response(id: id, result: result) rescue MCP::Server::RequestHandlerError => e handle_request_error(e, id, id_validation_pattern) - rescue StandardError => e + rescue StandardError + # The exception message is deliberately not echoed into `data`: it can carry + # internals that must not reach untrusted clients (CWE-209). error_response(id: id, id_validation_pattern: id_validation_pattern, error: { code: ErrorCode::INTERNAL_ERROR, message: "Internal error", - data: e.message, }) end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index ef8cfde..5fbb4a6 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -560,7 +560,14 @@ module MCP return end - Methods.ensure_capability!(method, capabilities) + begin + Methods.ensure_capability!(method, capabilities) + rescue Methods::MissingRequiredCapabilityError => e + # Re-raise through RequestHandlerError, the one channel whose message is + # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError` + # no longer echoes exception messages into the error `data` member (CWE-209). + raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e) + end # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), # so do not track it in the in-flight registry. @@ -864,8 +871,11 @@ module MCP # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. raise rescue => e + # `e.message` is deliberately not included: it can carry internals (class, method + # and host names) that must not reach untrusted clients (CWE-209). The original + # exception still reaches `configuration.exception_reporter` via `original_error`. raise RequestHandlerError.new( - "Internal error calling tool #{tool_name}: #{e.message}", + "Internal error calling tool #{tool_name}", request, error_type: :internal_error, original_error: e, diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index e823e42..07db0ec 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -310,7 +310,6 @@ describe JsonRpcHandler do assert_rpc_error expected_error: { code: -32603, message: "Internal error", - data: "Something bad happened", } assert_equal 1, @response[:id] assert_nil @response[:result] @@ -452,7 +451,6 @@ describe JsonRpcHandler do assert_rpc_error expected_error: { code: -32603, message: "Internal error", - data: "Something bad happened", } end diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 07d6072..0b26bd0 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -545,7 +545,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool tool_with_required_context: /, response[:error][:data]) + assert_equal "Internal error calling tool tool_with_required_context", response[:error][:data] end test "call_tool_with_args correctly detects server_context parameter presence" do diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 108e652..d3ebc6a 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -884,7 +884,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) + assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end @@ -941,7 +941,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) + assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end @@ -972,7 +972,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool tool_with_faulty_schema: Unexpected schema error/, response[:error][:data]) + assert_equal "Internal error calling tool tool_with_faulty_schema", response[:error][:data] end test "#handle tools/call returns JSON-RPC error for unknown tool" do @@ -1727,7 +1727,7 @@ module MCP assert_equal(["tool failure", "around ensure boom"], reported) assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) - assert_equal("around ensure boom", response[:error][:data]) + assert_nil(response[:error][:data]) end test "#handle reports the same exception object reused across requests on every call" do @@ -1779,7 +1779,7 @@ module MCP response = server.handle(request) assert_equal([frozen_error], reported) - assert_includes(response[:error][:data], "frozen failure") + assert_equal("Internal error calling tool frozen_tool", response[:error][:data]) end test "#handle still reports via exception_reporter when around_request swallows the tool failure" do @@ -2635,7 +2635,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, response[:error][:data]) + assert_equal "Internal error calling tool invalid_structured_content_tool", response[:error][:data] end test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do @@ -2664,7 +2664,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, response[:error][:data]) + assert_equal "Internal error calling tool missing_structured_content_tool", response[:error][:data] end test "tools/call skips output schema validation for error responses" do From ff48f5c6dd213782d86e83d093af04b225932277 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 01:17:17 +0900 Subject: [PATCH 177/247] [Conformance] Raise `ResourceNotFoundError` for unknown resource URIs ## Motivation and Context Per SEP-2164, a `resources/read` request for a nonexistent resource answers with JSON-RPC error -32602 carrying the requested URI in `error.data`, and MUST NOT return an empty `contents` array. The SDK supports this via `MCP::Server::ResourceNotFoundError` (#402), but the conformance fixture's `resources_read_handler` returned an empty array for unknown URIs, so the `sep-2164-resource-not-found` scenario of the 2026-07-28 conformance requirements fails on the fixture ("Server returned a result with an empty contents array"). The gap never surfaced before because the scenario is part of the 2026-07-28 requirement set only; the 2025-11-25 suite the fixture has been passing does not run it. The fixture now raises `ResourceNotFoundError`, which is also the documented pattern for `resources_read_handler` users. ## How Has This Been Tested? - `sep-2164-resource-not-found` passes 4/4 checks at `--spec-version 2026-07-28` against the fixture server. - The `--requirements 2025-11-25` server leg passes 78/78, and each resources scenario reports no change, so known URIs are unaffected. - RuboCop reports no offenses on the changed file. ## Breaking Changes None. The change is confined to the conformance fixture; the shipped gem is untouched. --- conformance/server.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/conformance/server.rb b/conformance/server.rb index 7b697d8..4b303d7 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -577,7 +577,9 @@ module Conformance ).to_h, ] else - [] + # Per SEP-2164, an unknown URI answers with -32602 carrying the URI in `error.data`; + # an empty `contents` array is forbidden. + raise MCP::Server::ResourceNotFoundError.new(uri, params) end end end From d0d7308a89d6b82f41f6d3198a5b0a5efc5ceaf1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 4 Aug 2026 03:35:35 +0900 Subject: [PATCH 178/247] Finalize `server/discover` and Add Client Modern Lifecycle Support per SEP-2575 ## Motivation and Context Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic. Server side, `Server#discover` reaches its spec-final shape: - `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: legacy versions are negotiated via `initialize`, not selected from discovery. This also closes an interop hazard of the interim response: auto-negotiating TS and Python clients treat any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`, so advertising only legacy versions made them fail outright. - The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise. - The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002 (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`. Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default: - `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`). It works before `connect`; the stdio transport's connect guard exempts `server/discover` and spawns the subprocess on demand. - `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:` (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover` and falls back to the legacy handshake on any discovery failure, including a successful discovery without a mutually supported modern version (rollout tolerance for servers that answer discovery while only serving legacy versions). Custom transports whose `connect` does not declare `mode:` keep receiving the historical legacy call shape, detected by keyword introspection; a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError` instead of silently downgrading. - The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`, instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version, the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here as `mode: :modern` with `protocol_version: "2026-07-28"`. - An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe, so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle. `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools and when using server-initiated requests, which exist only on the legacy lifecycle. - Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference: `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one), `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta` `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover. - The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK) when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback instead of blocking `connect` forever. Regular requests keep the unbounded default. - In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize` is never sent. - The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake and its scenario servers do not expect a preceding probe. Resolves #389. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values); the existing discover assertions across `server_test.rb` and both transport test files were updated to the modern-only `supportedVersions`. New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError` on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`, the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`, `ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection, and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence. New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks, and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`, modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout default applying only while probing, and the fallback when a server never answers the probe. ## Breaking Changes `server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`); this method has not shipped in a gem release yet, so no released behavior changes. On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation `protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected. --- README.md | 45 ++- conformance/client.rb | 3 + lib/mcp/client.rb | 150 ++++++++- lib/mcp/client/http.rb | 242 ++++++++++---- lib/mcp/client/modern_envelope.rb | 32 ++ lib/mcp/client/stdio.rb | 297 ++++++++++++++---- lib/mcp/request_envelope.rb | 5 + lib/mcp/server.rb | 20 +- test/mcp/client/http_test.rb | 121 +++++++ test/mcp/client/stdio_test.rb | 192 +++++++++++ test/mcp/client_test.rb | 212 +++++++++++++ .../server/transports/stdio_transport_test.rb | 2 +- .../streamable_http_transport_test.rb | 6 +- test/mcp/server_test.rb | 31 +- 14 files changed, 1205 insertions(+), 153 deletions(-) create mode 100644 lib/mcp/client/modern_envelope.rb diff --git a/README.md b/README.md index 000e6e2..13803a2 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,14 @@ It implements the Model Context Protocol specification, handling model context r ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities -- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, - and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern `supportedVersions`, + `capabilities`, `instructions`, the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional + `io.modelcontextprotocol/serverInfo` stamp in the result `_meta`, and responds before `initialize` + and without an `Mcp-Session-Id`. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope + (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, + and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates + the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips + the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas @@ -2376,6 +2382,41 @@ This class supports: Clients are initialized with a transport layer instance that handles the low-level communication mechanics. Authorization is handled by the transport layer. +### Lifecycle Negotiation (SEP-2575) + +`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled +`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes `server/discover` first and adopts +the stateless modern lifecycle (MCP 2026-07-28) when the server serves it, falling back to +the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare +a `mode:` keyword always receive the classic call shape, unchanged. + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers +client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe +``` + +Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process) +and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on +the legacy lifecycle. + +Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result, +their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`, +top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`) +on modern. Code that should work against both lifecycles can use the era-independent readers instead: + +```ruby +client.protocol_version # negotiated or adopted version, either lifecycle +client.server_capabilities # capabilities Hash, either lifecycle +client.instructions # instructions text, either lifecycle +client.server_implementation # server name/version; nil when a modern server does not identify itself +``` + +Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded, +the server now serves the modern lifecycle and the automatic negotiation adopted it. +Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. + ## Transport Layer Interface If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: diff --git a/conformance/client.rb b/conformance/client.rb index c41a30b..c103414 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -171,9 +171,12 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation # free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace # and a failing exit status. begin + # The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle: + # the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect. client.connect( client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, capabilities: capabilities, + mode: :legacy, ) case scenario diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 409f532..3a35495 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "client/elicitation" +require_relative "client/modern_envelope" require_relative "client/oauth" require_relative "client/stdio" require_relative "client/http" @@ -79,6 +80,18 @@ module MCP end end + # The server's `DiscoverResult` (MCP 2026-07-28, SEP-2575): the modern protocol versions it serves, + # its capabilities and identity, optional instructions, and the REQUIRED `ttlMs`/`cacheScope` cache hints. + DiscoverResult = Struct.new( + :supported_versions, + :capabilities, + :server_info, + :instructions, + :ttl_ms, + :cache_scope, + keyword_init: true, + ) + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. @@ -95,14 +108,43 @@ module MCP # So keeping it public attr_reader :transport - # The server's `InitializeResult` (protocol version, capabilities, server info, - # instructions), as reported by the transport after a successful `connect`. - # Returns `nil` before `connect`, after `close`, or when the transport does - # not expose a cached handshake result. + # The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575): + # after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption + # it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`). + # Code that must work against both eras should prefer the era-independent readers {#protocol_version}, {#server_capabilities}, + # {#instructions}, and {#server_implementation}; this raw form remains the window to everything they do not cover + # (`supportedVersions`, cache hints, `_meta`, extension data). Returns `nil` before `connect`, after `close`, + # or when the transport does not expose a cached handshake result. def server_info transport.server_info if transport.respond_to?(:server_info) end + # The protocol version in use on this connection, independent of its era: + # the version negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). + # Returns `nil` before `connect`. + def protocol_version + return transport.protocol_version if transport.respond_to?(:protocol_version) + + server_info&.dig("protocolVersion") + end + + # The server's capabilities Hash, present in both eras. Returns `nil` before `connect`. + def server_capabilities + server_info&.dig("capabilities") + end + + # The server's instructions text, present in both eras when provided. + def instructions + server_info&.dig("instructions") + end + + # The server's identity (`name`/`version`), independent of where the era puts it: top-level `serverInfo` + # on legacy results, the optional `_meta` `io.modelcontextprotocol/serverInfo` stamp on modern results. + # Returns `nil` when a modern server does not identify itself. + def server_implementation + server_info&.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || server_info&.dig("serverInfo") + end + # Performs the MCP `initialize` handshake by delegating to the transport # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's # `InitializeResult`. @@ -115,16 +157,62 @@ module MCP # @param capabilities [Hash] Capabilities advertised by the client. May include # an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers, # e.g. `{ extensions: { "com.example/feature" => {} } }`. - # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport - # does not expose an explicit handshake. + # @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose + # `connect` declares `mode:` (the bundled `MCP::Client::HTTP` and `MCP::Client::Stdio`) + # negotiate with `:auto`: probe `server/discover` first and fall back to the legacy handshake + # when the server does not serve a mutually supported modern version. Transports without `mode:` + # keep receiving the historical legacy call shape. `:legacy` forces the `initialize` handshake + # exactly as before; `:modern` requires the modern lifecycle and fails without a mutual modern version. + # Passing an explicit `protocol_version` from a legacy generation (e.g. `"2025-11-25"`) pins + # the legacy handshake without a probe, so an explicitly requested version is never overridden + # by the default negotiation. + # @return [Hash, nil] The server's `InitializeResult` (legacy) or `DiscoverResult` (modern), + # or `nil` when the transport does not expose an explicit handshake. + # Prefer the era-independent readers over inspecting this Hash directly. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}) + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: nil) return unless transport.respond_to?(:connect) - transport.connect( - client_info: client_info, - protocol_version: protocol_version, - capabilities: capabilities, + effective_mode = resolve_connect_mode(mode, protocol_version) + + if effective_mode == :legacy + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + ) + else + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + mode: effective_mode, + ) + end + end + + # Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery + # that works before (or instead of) `connect`. + # + # @param meta [Hash, nil] Additional `_meta` entries to send with the request. + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [MCP::Client::DiscoverResult] + # @raise [ServerError] If the server returns a JSON-RPC error. + # @raise [ValidationError] If the response `result` is missing or not a Hash. + def discover(meta: nil, cancellation: nil) + response = request(method: Methods::SERVER_DISCOVER, meta: meta, cancellation: cancellation) + result = response.is_a?(Hash) ? response["result"] : nil + raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) + + DiscoverResult.new( + supported_versions: result["supportedVersions"], + capabilities: result["capabilities"], + # The finalized spec (PR #3002) stamps the server identity into the result `_meta`; + # the top-level fallback tolerates servers built against the frozen SEP text. + server_info: result.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || result["serverInfo"], + instructions: result["instructions"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], ) end @@ -485,6 +573,46 @@ module MCP private + # Resolves the effective SEP-2575 lifecycle mode for `connect`: + # + # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe, + # so the default negotiation can never override a version the caller asked for. + # - Absent an explicit mode, transports declaring `mode:` negotiate with `:auto`; other transports keep + # the historical legacy call shape. + # - An explicit `:modern`/`:auto` on a transport without `mode:` raises, rather than silently downgrading to + # a lifecycle the caller did not ask for. + def resolve_connect_mode(mode, protocol_version) + unless [nil, :legacy, :modern, :auto].include?(mode) + raise ArgumentError, "mode must be :legacy, :modern, or :auto" + end + + return :legacy if mode == :legacy + return :legacy if mode.nil? && protocol_version && !Configuration.modern_protocol_version?(protocol_version) + + if transport_connect_accepts_mode? + mode || :auto + elsif mode.nil? + :legacy + else + raise ArgumentError, "transport does not support mode: #{mode.inspect}" + end + end + + # `mode:` is forwarded only when the transport's `connect` declares it as a keyword. + # A bare `**kwargs` deliberately does not count, so wrappers and test doubles that + # absorb arbitrary keywords keep the historical legacy call shape. Objects that + # dispatch `connect` through `method_missing` (e.g. mocks) may not support `method`, + # which reads as not declaring `mode:`. + def transport_connect_accepts_mode? + connect_method = begin + transport.method(:connect) + rescue NameError + return false + end + + connect_method.parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :mode } + end + # Walks every page of a list endpoint, following `next_cursor`, and returns # the page results. The `seen` set guards against a server that repeats or # cycles cursors, so the loop always terminates. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 8a572cf..538bfce 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -6,6 +6,7 @@ require_relative "../configuration" require_relative "../methods" require_relative "../protocol_deprecations" require_relative "../version" +require_relative "modern_envelope" module MCP class Client @@ -252,6 +253,8 @@ module MCP @connected = false @server_request_handlers = {} @listener_thread = nil + @modern_client_info = nil + @modern_capabilities = nil end # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. @@ -287,73 +290,37 @@ module MCP # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error # or a malformed result. + # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs + # the handshake below, `:modern` skips it and probes `server/discover`, + # and `:auto` probes `server/discover` first, falling back to the legacy handshake + # when the server does not serve a mutually supported modern version. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}) + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION - response = send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::INITIALIZE, - params: { - protocolVersion: protocol_version, - capabilities: capabilities, - clientInfo: client_info, - }, - }) - - if response.is_a?(Hash) && response.key?("error") - clear_session - error = response["error"] - raise RequestHandlerError.new( - "Server initialization failed: #{error["message"]}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) + case mode + when :legacy + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :modern + connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :auto + connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + else + raise ArgumentError, "mode must be :legacy, :modern, or :auto" end + end - unless response.is_a?(Hash) && response["result"].is_a?(Hash) - clear_session - raise RequestHandlerError.new( - "Server initialization failed: missing result in response", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - @server_info = response["result"] - negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) - clear_session - raise RequestHandlerError.new( - "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - - begin - send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - method: MCP::Methods::NOTIFICATIONS_INITIALIZED, - }) - rescue StandardError - clear_session - raise - end - - @connected = true - start_listening if @server_request_handlers.any? - @server_info + # Whether the transport operates in the stateless modern lifecycle (SEP-2575): + # no handshake was performed and every request carries the `_meta` envelope. + def modern? + !@modern_client_info.nil? end # Returns true once `connect` has completed the full handshake - # (`initialize` response received and `notifications/initialized` sent). + # (`initialize` response received and `notifications/initialized` sent), + # or once the modern lifecycle was adopted via `server/discover`. # Returns false before the first handshake and after `close`. def connected? @connected @@ -373,6 +340,18 @@ module MCP # a cancellation referring to an unknown request id when the cancel POST happens to arrive first. # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation def send_request(request:) + # Modern requests (never notifications, whose `_meta` has no envelope) carry + # the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers` + # matches it by construction. + if modern? && (request[:id] || request["id"]) + request = ModernEnvelope.stamp( + request, + protocol_version: @protocol_version, + client_info: @modern_client_info, + capabilities: @modern_capabilities, + ) + end + method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false @@ -535,6 +514,153 @@ module MCP attr_reader :headers + def connect_legacy(client_info:, protocol_version:, capabilities:) + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + }) + + if response.is_a?(Hash) && response.key?("error") + clear_session + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response.is_a?(Hash) && response["result"].is_a?(Hash) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) + + begin + send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + }) + rescue StandardError + clear_session + raise + end + + @connected = true + start_listening if @server_request_handlers.any? + @server_info + end + + # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. + # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. + def connect_modern(client_info:, protocol_version:, capabilities:) + version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION + unless MCP::Configuration.modern_protocol_version?(version) + raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" + end + + enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities) + + begin + result = probe_discover + rescue StandardError + leave_modern_mode + raise + end + + supported = result["supportedVersions"] + unless supported.is_a?(Array) && supported.include?(version) + leave_modern_mode + raise RequestHandlerError.new( + "Server discovery failed: no mutually supported modern protocol version " \ + "(server supports #{supported.inspect})", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + @server_info = result + @connected = true + @server_info + end + + # Probes `server/discover` and adopts the modern lifecycle when the server serves + # a mutually supported modern version; otherwise falls back to the legacy handshake. + # The fallback intentionally covers a successful discovery without a mutual modern + # version as well: during the 2026-07-28 rollout a server may answer discovery while + # only serving legacy versions. + def connect_auto(client_info:, protocol_version:, capabilities:) + connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + rescue RequestHandlerError + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + end + + def enter_modern_mode(protocol_version:, client_info:, capabilities:) + @modern_client_info = client_info + @modern_capabilities = capabilities || {} + # `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header, + # which the modern lifecycle requires to match the `_meta`-carried version. + @protocol_version = protocol_version + end + + def leave_modern_mode + @modern_client_info = nil + @modern_capabilities = nil + @protocol_version = nil + end + + def probe_discover + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::SERVER_DISCOVER, + }) + + if response.is_a?(Hash) && response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server discovery failed: #{error["message"]}", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result = response.is_a?(Hash) ? response["result"] : nil + unless result.is_a?(Hash) + raise RequestHandlerError.new( + "Server discovery failed: missing result in response", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result + end + def client require_faraday! @client ||= Faraday.new(url) do |faraday| @@ -730,6 +856,8 @@ module MCP @protocol_version = nil @server_info = nil @connected = false + @modern_client_info = nil + @modern_capabilities = nil end # Opens the standalone GET SSE listening stream on a background thread so server-to-client requests diff --git a/lib/mcp/client/modern_envelope.rb b/lib/mcp/client/modern_envelope.rb new file mode 100644 index 0000000..b9aed32 --- /dev/null +++ b/lib/mcp/client/modern_envelope.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../request_envelope" + +module MCP + class Client + # Stamps the SEP-2575 per-request `_meta` envelope onto outgoing modern requests. + # The reserved key names are shared with the server-side `MCP::RequestEnvelope`. + # Reserved keys always win over caller-supplied `_meta` entries because they are + # wire vocabulary the SDK is responsible for; every other entry is preserved. + module ModernEnvelope + extend self + + # Returns a copy of `request` with `params._meta` carrying the modern triple. + # Neither `request` nor its nested hashes are mutated. + def stamp(request, protocol_version:, client_info:, capabilities:) + params_key = request.key?("params") ? "params" : :params + params = request[params_key] || {} + meta_key = params.key?("_meta") ? "_meta" : :_meta + meta = params[meta_key] || {} + + stamped_meta = meta.merge( + RequestEnvelope::PROTOCOL_VERSION_META_KEY.to_sym => protocol_version, + RequestEnvelope::CLIENT_INFO_META_KEY.to_sym => client_info, + RequestEnvelope::CLIENT_CAPABILITIES_META_KEY.to_sym => capabilities, + ) + + request.merge(params_key => params.merge(meta_key => stamped_meta)) + end + end + end +end diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 747687b..d26528f 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -9,6 +9,7 @@ require_relative "../configuration" require_relative "../methods" require_relative "../protocol_deprecations" require_relative "../version" +require_relative "modern_envelope" module MCP class Client @@ -27,6 +28,12 @@ module MCP # realistic JSON-RPC frame, including base64-embedded images. MAX_LINE_BYTES = 4 * 1024 * 1024 + # Seconds the `server/discover` probe may wait when no `read_timeout` was configured. + # A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one + # that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever. + # Matches the C# SDK's `DiscoverProbeTimeout` default. + DEFAULT_DISCOVER_PROBE_TIMEOUT = 5 + attr_reader :command, :args, :env, :server_info def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) @@ -50,6 +57,9 @@ module MCP @started = false @initialized = false @server_info = nil + @modern_protocol_version = nil + @modern_client_info = nil + @modern_capabilities = nil # Serializes writes to `@stdin` so a request line and a notification line emitted from # different threads (e.g. cancellation) cannot interleave on the wire. @write_mutex = Mutex.new @@ -73,82 +83,47 @@ module MCP # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, # a malformed result, or an unsupported protocol version. + # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs + # the handshake below, `:modern` skips it and probes `server/discover`, + # and `:auto` probes `server/discover` first, falling back to the legacy handshake + # when the server does not serve a mutually supported modern version. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}) - return @server_info if @initialized + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) + return @server_info if connected? start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION - init_request = { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::INITIALIZE, - params: { - protocolVersion: protocol_version, - capabilities: capabilities, - clientInfo: client_info, - }, - } - - write_message(init_request) - response = read_response(init_request) - - if response.key?("error") - error = response["error"] - raise RequestHandlerError.new( - "Server initialization failed: #{error["message"]}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) + case mode + when :legacy + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :modern + connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :auto + connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + else + raise ArgumentError, "mode must be :legacy, :modern, or :auto" end - - unless response["result"].is_a?(Hash) - raise RequestHandlerError.new( - "Server initialization failed: missing result in response", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - @server_info = response["result"] - - negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) - # Per spec, if the client does not support the server's returned protocol version, - # the client SHOULD disconnect. Roll back the cached `InitializeResult` before - # raising so a retry starts without a stale `server_info`. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation - @server_info = nil - raise RequestHandlerError.new( - "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - - begin - notification = { - jsonrpc: JsonRpcHandler::Version::V2_0, - method: MCP::Methods::NOTIFICATIONS_INITIALIZED, - } - write_message(notification) - rescue StandardError - @server_info = nil - raise - end - - @initialized = true - @server_info end - # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`. + # Whether the transport operates in the stateless modern lifecycle (SEP-2575): + # no handshake was performed and every request carries the `_meta` envelope. + def modern? + !@modern_client_info.nil? + end + + # The protocol version in use on this connection, independent of its era: + # negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). + # Returns `nil` before `connect` and after `close`. + def protocol_version + @modern_protocol_version || (@server_info && @server_info["protocolVersion"]) + end + + # Returns true once `connect` has completed the handshake or adopted the modern lifecycle. + # Returns false before the handshake and after `close`. def connected? - @initialized + @initialized || modern? end # Transports may yield once the request line has been written to `@stdin`. @@ -156,7 +131,16 @@ module MCP # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`, # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request. def send_request(request:) - raise "MCP::Client#connect must be called before sending requests." unless @initialized + method = request[:method] || request["method"] + if method == MCP::Methods::SERVER_DISCOVER + # `server/discover` (SEP-2575) is sessionless capability discovery that + # works before (or instead of) `connect`. + start unless @started + elsif !connected? + raise "MCP::Client#connect must be called before sending requests." + end + + request = stamp_modern(request) @write_mutex.synchronize do write_message(request) @@ -169,7 +153,7 @@ module MCP # `notifications/cancelled` for an in-flight request. def send_notification(notification:) start unless @started - connect unless @initialized + connect unless connected? @write_mutex.synchronize { write_message(notification) } nil @@ -231,10 +215,187 @@ module MCP @started = false @initialized = false @server_info = nil + leave_modern_mode end private + def connect_legacy(client_info:, protocol_version:, capabilities:) + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + + init_request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + } + + write_message(init_request) + response = read_response(init_request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response["result"].is_a?(Hash) + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # Per spec, if the client does not support the server's returned protocol version, + # the client SHOULD disconnect. Roll back the cached `InitializeResult` before raising + # so a retry starts without a stale `server_info`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation + @server_info = nil + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) + + begin + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + } + write_message(notification) + rescue StandardError + @server_info = nil + raise + end + + @initialized = true + @server_info + end + + # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. + # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. + def connect_modern(client_info:, protocol_version:, capabilities:) + version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION + unless MCP::Configuration.modern_protocol_version?(version) + raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" + end + + @modern_protocol_version = version + @modern_client_info = client_info + @modern_capabilities = capabilities || {} + + begin + result = with_probe_read_timeout { probe_discover } + rescue StandardError + leave_modern_mode + raise + end + + supported = result["supportedVersions"] + unless supported.is_a?(Array) && supported.include?(version) + leave_modern_mode + raise RequestHandlerError.new( + "Server discovery failed: no mutually supported modern protocol version " \ + "(server supports #{supported.inspect})", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + @server_info = result + @server_info + end + + # Probes `server/discover` and adopts the modern lifecycle when the server serves + # a mutually supported modern version; otherwise falls back to the legacy handshake. + # The fallback intentionally covers a successful discovery without a mutual modern + # version as well: during the 2026-07-28 rollout a server may answer discovery while + # only serving legacy versions. + def connect_auto(client_info:, protocol_version:, capabilities:) + connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + rescue RequestHandlerError + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + end + + def leave_modern_mode + @modern_protocol_version = nil + @modern_client_info = nil + @modern_capabilities = nil + end + + # Bounds the probe read when the caller configured no `read_timeout`, and only for the probe: + # regular requests keep the unbounded default so long-running tools are unaffected. + # The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence + # and falls back on. + def with_probe_read_timeout + return yield if @read_timeout + + @read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT + begin + yield + ensure + @read_timeout = nil + end + end + + def probe_discover + request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::SERVER_DISCOVER, + } + + @write_mutex.synchronize { write_message(stamp_modern(request)) } + response = read_response(request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server discovery failed: #{error["message"]}", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result = response["result"] + unless result.is_a?(Hash) + raise RequestHandlerError.new( + "Server discovery failed: missing result in response", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result + end + + # Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple. + def stamp_modern(request) + return request unless modern? && (request[:id] || request["id"]) + + ModernEnvelope.stamp( + request, + protocol_version: @modern_protocol_version, + client_info: @modern_client_info, + capabilities: @modern_capabilities, + ) + end + def write_message(message) ensure_running! json = JSON.generate(message) diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb index 51834e8..bdf9c25 100644 --- a/lib/mcp/request_envelope.rb +++ b/lib/mcp/request_envelope.rb @@ -17,6 +17,11 @@ module MCP # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format. LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" + # Result-side counterpart of the request envelope: the server's identity rides in + # the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was + # finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it. + SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" + REQUIRED_META_KEYS = [ PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index ef8cfde..dcb52a6 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -767,20 +767,26 @@ module MCP end end - # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery. + # Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery. # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark # the session initialized, and responds regardless of capability declarations or initialization state, - # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered - # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints - # are not included here yet. + # so clients can probe a server before (or instead of) `initialize`. + # + # `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: + # legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope` + # cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results), + # so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only) + # fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides + # in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized + # spec (PR #3002), unfiltered because discovery happens before version negotiation. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 def discover(_request) { - supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, + supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, capabilities: capabilities, - serverInfo: server_info, instructions: instructions, - }.compact + _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info }, + }.compact.merge(ttlMs: ttl_ms || 0, cacheScope: cache_scope || "private") end def configure_logging_level(request, session: nil) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 3fdb527..da1a016 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1844,6 +1844,109 @@ module MCP refute_predicate(client, :connected?) end + def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle + discover_stub = stub_discover + + result = client.connect(mode: :modern) + + assert_requested(discover_stub) + assert_predicate(client, :connected?) + assert_predicate(client, :modern?) + assert_equal(["2026-07-28"], result["supportedVersions"]) + assert_equal(result, client.server_info) + assert_equal("2026-07-28", client.protocol_version) + end + + def test_modern_requests_carry_the_envelope_and_matching_header + stub_discover + client.connect( + mode: :modern, + client_info: { name: "my-app", version: "9.9" }, + capabilities: { elicitation: {} }, + ) + + request_stub = stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + meta = body.dig("params", "_meta") + body["method"] == "tools/list" && + req.headers["Mcp-Protocol-Version"] == "2026-07-28" && + meta == { + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" }, + "io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} }, + } + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_requested(request_stub) + end + + def test_connect_modern_fails_without_a_mutual_modern_version + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) } + + assert_includes(error.message, "no mutually supported modern protocol version") + refute_predicate(client, :connected?) + refute_predicate(client, :modern?) + end + + def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { error: { code: -32601, message: "Method not found" } }.to_json, + ) + init_stub = stub_initialize + notification_stub = stub_notification + + result = client.connect(mode: :auto) + + assert_requested(init_stub) + assert_requested(notification_stub) + refute_predicate(client, :modern?) + assert_predicate(client, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + end + + def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version + # Rollout tolerance: a server may answer discovery while only serving legacy versions; + # auto mode falls back to the legacy handshake. + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + init_stub = stub_initialize + stub_notification + + client.connect(mode: :auto) + + assert_requested(init_stub) + refute_predicate(client, :modern?) + assert_predicate(client, :connected?) + end + + def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode + assert_raises(ArgumentError) { client.connect(mode: :bogus) } + assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") } + end + def test_reconnect_after_close stub_initialize stub_notification @@ -1949,6 +2052,24 @@ module MCP .to_return(status: 202, body: "") end + def stub_discover + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + result: { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + _meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } }, + ttlMs: 0, + cacheScope: "private", + }, + }.to_json, + ) + end + def stub_request(method, url) WebMock.stub_request(method, url) end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 158fa93..4a04bfe 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1414,6 +1414,198 @@ module MCP [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] end + def test_send_request_allows_server_discover_before_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + response = { + jsonrpc: "2.0", + id: discover_request["id"], + result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" }, + } + stdout_write.puts(JSON.generate(response)) + stdout_write.flush + end + + # No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery. + response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" }) + + assert_equal(["2026-07-28"], response.dig("result", "supportedVersions")) + refute_predicate(transport, :connected?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_modern_probes_discover_and_stamps_requests + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received = [] + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + received << discover_request + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + result: { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + }, + })) + stdout_write.flush + + tools_request = JSON.parse(stdin_read.gets) + received << tools_request + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } })) + stdout_write.flush + end + + result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" }) + response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" }) + + assert_equal(["2026-07-28"], result["supportedVersions"]) + assert_predicate(transport, :modern?) + assert_predicate(transport, :connected?) + assert_equal("2026-07-28", transport.protocol_version) + assert_equal("t1", response["id"]) + + # No `initialize` was sent; both frames carry the SEP-2575 envelope. + assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] }) + triple = received[1].dig("params", "_meta") + assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"]) + assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"]) + assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"]) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_auto_falls_back_to_the_legacy_handshake + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + error: { code: -32601, message: "Method not found" }, + })) + stdout_write.flush + + init_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + }, + })) + stdout_write.flush + + # `notifications/initialized` + stdin_read.gets + end + + result = transport.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal("2025-11-25", transport.protocol_version) + refute_predicate(transport, :modern?) + assert_predicate(transport, :connected?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.stubs(:start) + + observed_timeout = nil + transport.define_singleton_method(:probe_discover) do + observed_timeout = @read_timeout + raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error) + end + transport.define_singleton_method(:connect_legacy) do |**| + { "protocolVersion" => "2025-11-25" } + end + + result = transport.connect(mode: :auto) + + assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout) + assert_nil(transport.instance_variable_get(:@read_timeout)) + assert_equal("2025-11-25", result["protocolVersion"]) + end + + def test_connect_auto_falls_back_when_the_server_never_answers_the_probe + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + # A configured `read_timeout` also bounds the probe; the server below reads + # the `server/discover` request and never responds, which must count as + # legacy evidence instead of blocking `connect` forever. + transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05) + + server_thread = Thread.new do + # Silently swallow the probe. + stdin_read.gets + + init_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + }, + })) + stdout_write.flush + + # `notifications/initialized` + stdin_read.gets + end + + result = transport.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + refute_predicate(transport, :modern?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + def mock_wait_thread thread = mock("wait_thread") thread.stubs(:alive?).returns(true) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index a48a846..bc7cd14 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -85,6 +85,218 @@ module MCP assert_includes(error.message, "does not support server-to-client requests") end + # A transport that declares `mode:` on `connect`, recording what it received. + # The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from + # "Client forwarded mode: :legacy". + class ModeAwareTransport + attr_reader :received + + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded) + @received = { + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + mode: mode, + } + { "supportedVersions" => ["2026-07-28"] } + end + end + + def test_connect_defaults_to_auto_on_transports_declaring_mode + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect + + assert_equal(:auto, transport.received[:mode]) + end + + def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode + # Custom transports whose `connect` does not declare `mode:` keep receiving + # the historical call shape, so the `:auto` default cannot break them. + transport = mock + transport.expects(:connect).with( + client_info: nil, protocol_version: nil, capabilities: {}, + ).returns({ "protocolVersion" => "2025-11-25" }).once + + Client.new(transport: transport).connect + end + + def test_connect_routes_explicit_legacy_through_the_legacy_call_shape + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(mode: :legacy) + + assert_equal(:not_forwarded, transport.received[:mode]) + end + + def test_connect_pins_legacy_for_an_explicit_stable_protocol_version + # An explicitly requested legacy-generation version must never be overridden + # by the default negotiation adopting the modern lifecycle. + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(protocol_version: "2025-11-25") + + assert_equal(:not_forwarded, transport.received[:mode]) + assert_equal("2025-11-25", transport.received[:protocol_version]) + end + + def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(protocol_version: "2026-07-28") + + assert_equal(:auto, transport.received[:mode]) + end + + def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode + transport = mock + transport.expects(:connect).never + + error = assert_raises(ArgumentError) do + Client.new(transport: transport).connect(mode: :modern) + end + + assert_includes(error.message, "does not support mode:") + end + + def test_connect_rejects_unknown_modes + transport = ModeAwareTransport.new + + assert_raises(ArgumentError) do + Client.new(transport: transport).connect(mode: :bogus) + end + end + + def test_discover_returns_a_discover_result + transport = mock + transport.expects(:send_request).with do |args| + args[:request][:method] == "server/discover" + end.returns({ + "result" => { + "supportedVersions" => ["2026-07-28"], + "capabilities" => { "tools" => {} }, + "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } }, + "instructions" => "Optional instructions", + "ttlMs" => 3_600_000, + "cacheScope" => "public", + }, + }).once + + result = Client.new(transport: transport).discover + + assert_equal(["2026-07-28"], result.supported_versions) + assert_equal({ "tools" => {} }, result.capabilities) + assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info) + assert_equal("Optional instructions", result.instructions) + assert_equal(3_600_000, result.ttl_ms) + assert_equal("public", result.cache_scope) + end + + def test_discover_maps_a_top_level_server_info_as_a_fallback + # Servers built against the frozen SEP text still stamp `serverInfo` at the top level; + # the finalized spec moved it into the result `_meta`. + transport = mock + transport.expects(:send_request).returns({ + "result" => { + "supportedVersions" => ["2026-07-28"], + "serverInfo" => { "name" => "sep-era-server", "version" => "1.0" }, + }, + }).once + + result = Client.new(transport: transport).discover + + assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info) + end + + def test_discover_raises_validation_error_on_missing_result + transport = mock + transport.expects(:send_request).returns({}).once + + assert_raises(Client::ValidationError) do + Client.new(transport: transport).discover + end + end + + def test_discover_raises_server_error_on_jsonrpc_error + transport = mock + transport.expects(:send_request).returns({ + "error" => { "code" => -32601, "message" => "Method not found" }, + }).once + + error = assert_raises(Client::ServerError) do + Client.new(transport: transport).discover + end + + assert_equal(-32601, error.code) + end + + def test_era_independent_readers_on_a_legacy_result + transport = mock + transport.stubs(:server_info).returns({ + "protocolVersion" => "2025-11-25", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "legacy-server", "version" => "1.0" }, + "instructions" => "Be helpful", + }) + + client = Client.new(transport: transport) + + assert_equal("2025-11-25", client.protocol_version) + assert_equal({ "tools" => {} }, client.server_capabilities) + assert_equal("Be helpful", client.instructions) + assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation) + end + + def test_era_independent_readers_on_a_modern_result + # A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes + # from the transport. `serverInfo` sits in the result `_meta` per the final spec. + transport = mock + transport.stubs(:protocol_version).returns("2026-07-28") + transport.stubs(:server_info).returns({ + "supportedVersions" => ["2026-07-28"], + "capabilities" => { "tools" => {} }, + "instructions" => "Be helpful", + "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } }, + "ttlMs" => 0, + "cacheScope" => "private", + }) + + client = Client.new(transport: transport) + + assert_equal("2026-07-28", client.protocol_version) + assert_equal({ "tools" => {} }, client.server_capabilities) + assert_equal("Be helpful", client.instructions) + assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation) + end + + def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence + transport = mock + transport.stubs(:server_info).returns({ + "supportedVersions" => ["2026-07-28"], + "serverInfo" => { "name" => "top-level", "version" => "1.0" }, + }) + + assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation) + + anonymous = mock + anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] }) + + assert_nil(Client.new(transport: anonymous).server_implementation) + end + + def test_era_independent_readers_return_nil_before_connect + transport = mock + transport.stubs(:respond_to?).with(:server_info).returns(false) + transport.stubs(:respond_to?).with(:protocol_version).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.protocol_version) + assert_nil(client.server_capabilities) + assert_nil(client.instructions) + assert_nil(client.server_implementation) + end + def test_connected_delegates_to_transport_when_supported transport = mock transport.expects(:connected?).returns(true) diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 1b7d66d..1437877 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -583,7 +583,7 @@ module MCP modern_ping_request(id: 3), ]) - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) assert_equal :modern, session_era assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code) assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index b7ba329..3b2029d 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -660,7 +660,7 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") refute response[1].key?("mcp-session-id") end @@ -678,7 +678,7 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") end test "rejects duplicate SSE connection with 409" do @@ -5426,7 +5426,7 @@ module MCP assert_equal 200, response[0] refute response[1].key?("mcp-session-id") body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") end test "legacy POST rejects a modern envelope body without the modern header as -32020" do diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 108e652..06e0af6 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -129,10 +129,13 @@ module MCP response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) result = response[:result] - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions] + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions] assert_equal @server.capabilities, result[:capabilities] - assert_equal @server_name, result.dig(:serverInfo, :name) - assert_equal "1.2.3", result.dig(:serverInfo, :version) + # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp, + # not a top-level `serverInfo` field. + assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name) + assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version) + refute result.key?(:serverInfo) assert_equal "Optional instructions for the client", result[:instructions] end @@ -142,7 +145,7 @@ module MCP response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) - assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) end test "#handle server/discover does not mark the session initialized" do @@ -167,6 +170,26 @@ module MCP refute response[:result].key?(:instructions) end + test "#handle server/discover always carries the required cache hints with spec defaults" do + # Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope` + # are REQUIRED on `DiscoverResult`. + server = Server.new(name: "discover_test") + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal 0, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "#handle server/discover reuses the configured SEP-2549 cache hints" do + server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public") + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal 3_600_000, result[:ttlMs] + assert_equal "public", result[:cacheScope] + end + test "#define_custom_method raises for server/discover" do assert_raises(Server::MethodAlreadyDefinedError) do @server.define_custom_method(method_name: "server/discover") { {} } From 5f3a131e1a4282afbcaaef943e517638057960d5 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 03:06:55 +0900 Subject: [PATCH 179/247] Require the `Mcp-Method` header on the modern path ## Motivation and Context The modern Streamable HTTP path validated the SEP-2243 mirror headers only when present: an absent `Mcp-Method` or `Mcp-Name` was silently tolerated. The 2026-07-28 specification requires `Mcp-Method` on every request and `Mcp-Name` on `tools/call`, `resources/read`, and `prompts/get`, and lists a missing required standard header among the validation failures a server MUST reject; the TypeScript SDK enforces presence with `-32020` the same way. Tolerating absence also defeats the headers' purpose silently: they exist so intermediaries can route and inspect requests without parsing bodies. A modern POST without `Mcp-Method`, or a name-bearing modern POST whose body carries a target name without `Mcp-Name`, now answers HTTP 400 with `-32020` naming the missing header, exactly like a mismatch. `Mcp-Name` stays unrequired when the body carries no target name, matching the TypeScript SDK. The bundled `MCP::Client::HTTP` already sends both headers on every modern request, so SDK-to-SDK traffic is unaffected. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the missing `Mcp-Method` and missing `Mcp-Name` rejections, asserting the `-32020` code and the header name in the message. The `modern_rack_request` helper now mirrors a conforming client by deriving `Mcp-Method` from the body (with `method_header: nil` to omit it), so the existing modern-path tests exercise the requirement on every request. ## Breaking Changes None for conforming clients, which must already send these headers. Hand-rolled modern clients that omitted them are now rejected with `-32020` instead of being served; add the headers, or use the legacy lifecycle, to proceed. --- .../transports/streamable_http_transport.rb | 38 ++++++++++---- .../streamable_http_transport_test.rb | 51 ++++++++++++++++--- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4c19eac..760e536 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -593,23 +593,43 @@ module MCP ) end + # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods: + # the spec lists a missing required standard header among the validation failures that MUST be rejected, + # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare": + # the headers exist so intermediaries can route without parsing bodies, and a client that omits them + # defeats that contract silently. method_header = request.env["HTTP_MCP_METHOD"] - if method_header && method_header != body[:method] + if method_header.to_s.empty? + return header_mismatch_response( + "Mcp-Method header is required on the modern path", + body[:id], + ) + end + if method_header != body[:method] return header_mismatch_response( "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'", body[:id], ) end - name_header = request.env["HTTP_MCP_NAME"] - if name_header && NAME_BEARING_METHODS.include?(body[:method]) + if NAME_BEARING_METHODS.include?(body[:method]) body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil - decoded_name = decode_header_value(name_header) - if body_name && decoded_name != body_name - return header_mismatch_response( - "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", - body[:id], - ) + name_header = request.env["HTTP_MCP_NAME"] + if body_name + if name_header.to_s.empty? + return header_mismatch_response( + "Mcp-Name header is required for `#{body[:method]}`", + body[:id], + ) + end + + decoded_name = decode_header_value(name_header) + if decoded_name != body_name + return header_mismatch_response( + "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", + body[:id], + ) + 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 b7ba329..107ad05 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5340,6 +5340,31 @@ module MCP assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) end + test "modern POST without the Mcp-Method header returns 400 with -32020" do + # The header is required on every modern POST; a missing required standard header is + # a validation failure, matching the TypeScript SDK's enforcement. + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + method_header: nil, + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32020, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "Mcp-Method" + end + + test "modern POST without the Mcp-Name header on a name-bearing method returns 400 with -32020" do + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "some_tool", arguments: {} }), + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32020, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "Mcp-Name" + end + test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do # `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives # wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`. @@ -5410,6 +5435,7 @@ module MCP response = @transport.handle_request(modern_rack_request( modern_body("tools/call", { name: "guarded_tool", arguments: {} }), + headers: { "HTTP_MCP_NAME" => "guarded_tool" }, )) assert_equal 400, response[0] @@ -5600,13 +5626,24 @@ module MCP end # Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header. - def modern_rack_request(body, version: "2026-07-28", headers: {}) - create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }.merge(headers), - body, - ) + # Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST, + # so it defaults to the body's method. Pass `method_header: nil` to omit it + # (for tests of the requirement itself) or a String to send a specific value. + def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body) + derived_method = if method_header == :from_body + begin + parsed = JSON.parse(body) + parsed["method"] if parsed.is_a?(Hash) + rescue JSON::ParserError + nil + end + else + method_header + end + + base_headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version } + base_headers["HTTP_MCP_METHOD"] = derived_method if derived_method + create_rack_request("POST", "/", base_headers.merge(headers), body) end # Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope. From 9a62b9ae4f8482936f8b535cbd10f0406671c074 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 07:46:46 +0900 Subject: [PATCH 180/247] Suppress RuboCop extension suggestions (#484) This PR suppresses the following information: ```console $ bundle exec rubocop (snip) Tip: Based on detected gems, the following RuboCop extension libraries might be helpful: * rubocop-i18n (https://rubygems.org/gems/rubocop-i18n) You can opt out of this message by adding the following to your config (see https://docs.rubocop.org/rubocop/plugins.html#plugin-suggestions for more options): AllCops: SuggestExtensions: false ``` There's no need to introduce a third-party RuboCop extension unless there's a specific need. --- .rubocop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.rubocop.yml b/.rubocop.yml index f0c764e..aefa67a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ plugins: AllCops: TargetRubyVersion: 2.7 + SuggestExtensions: false Gemspec/DevelopmentDependencies: Enabled: true From 14e02da4752beeb8c5c19f5c8bd054bb67331f4c Mon Sep 17 00:00:00 2001 From: seunghan Kim Date: Sat, 8 Aug 2026 08:33:07 +0900 Subject: [PATCH 181/247] Expose the user-defined server_context in instrumentation data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APM integrations need to tag a span with the subject of the request, but the user-defined `server_context` hash passed to `Server.new` never reaches `around_request` or `instrumentation_callback`. The exception reporter's `server_context` argument is a different value — it describes where a failure occurred (`{ request: ... }`, `{ notification: ... }`), not who made the call — so there is currently no way to read the request subject from a hook. Add `Configuration#instrument_server_context`, off by default. When enabled, `instrument_call` puts the host's `server_context` into the instrumentation data before invoking `around_request`, so it is readable both before and after `request_handler.call`. The flag is opt-in rather than unconditional because the hash is application-supplied and may hold values a tracing backend should not receive. Hosts that do not expose a `server_context` reader are unaffected. `instrument_call` reads it through `self.server_context`: the existing `server_context:` keyword shadows the reader, and the value wanted here is the user-defined hash, not the reporter context. Follows the same shape as the earlier additions of `tool_arguments` (#218) and `client` (#221) to this hash. --- README.md | 28 +++++++++++- lib/mcp/configuration.rb | 25 ++++++++++- lib/mcp/instrumentation.rb | 6 +++ test/mcp/configuration_test.rb | 29 +++++++++++++ test/mcp/instrumentation_test.rb | 73 ++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 000e6e2..e5e6c1c 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,8 @@ server = MCP::Server.new( ``` This hash is then passed as the `server_context` keyword argument to tool and prompt calls. -Note that exception and instrumentation callbacks do not receive this user-defined hash. +Note that the exception reporter does not receive this user-defined hash, and instrumentation +callbacks omit it unless you opt in with `instrument_server_context`. See the relevant sections below for the arguments they receive. #### Request-specific `_meta` Parameter @@ -460,10 +461,31 @@ around_request = ->(data, &request_handler) { request_handler.call } **`data` availability by timing:** -- Before `request_handler.call`: `method` +- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled - After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` - Not available inside `around_request`: `duration` (added after `around_request` returns) +**Exposing the user-defined `server_context` (opt in):** + +`data` omits the user-defined `server_context` by default, because that hash is +application-supplied and may hold values a tracing backend should not receive. +Enable it when you need to tag spans with the request's subject: + +```ruby +MCP.configure do |config| + config.instrument_server_context = true + + config.around_request = ->(data, &request_handler) { + Sentry.set_user(id: data.dig(:server_context, :user_id)) + request_handler.call + } +end +``` + +`data[:server_context]` is the hash passed to `Server.new` — `nil` when the host +set none. It is not the exception reporter's context argument, which describes +where a failure occurred rather than who made the request. + > [!NOTE] > `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods > (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled @@ -515,6 +537,8 @@ It receives a hash with the following possible keys: - `error`: (String, optional) Error code if a lookup failed - `duration`: (Float) Duration of the call in seconds - `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request +- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when + `instrument_server_context` is enabled **Signature:** diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 390643b..0dac223 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -33,7 +33,7 @@ module MCP attr_writer :instrumentation_callback def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, - validate_tool_call_arguments: true, validate_tool_call_results: false) + validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false) @exception_reporter = exception_reporter @around_request = around_request @instrumentation_callback = instrumentation_callback @@ -43,9 +43,11 @@ module MCP end validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) validate_value_of_validate_tool_call_results!(validate_tool_call_results) + validate_value_of_instrument_server_context!(instrument_server_context) @validate_tool_call_arguments = validate_tool_call_arguments @validate_tool_call_results = validate_tool_call_results + @instrument_server_context = instrument_server_context end def protocol_version=(protocol_version) @@ -60,6 +62,16 @@ module MCP @validate_tool_call_arguments = validate_tool_call_arguments end + # Opt in to exposing the user-defined `server_context` in the + # `around_request` / `instrumentation_callback` data hash. Off by default: + # the hash is application-supplied and may hold values a tracing backend + # should not receive, so surfacing it has to be a deliberate choice. + def instrument_server_context=(instrument_server_context) + validate_value_of_instrument_server_context!(instrument_server_context) + + @instrument_server_context = instrument_server_context + end + def validate_tool_call_results=(validate_tool_call_results) validate_value_of_validate_tool_call_results!(validate_tool_call_results) @@ -107,6 +119,10 @@ module MCP attr_reader :validate_tool_call_arguments attr_reader :validate_tool_call_results + def instrument_server_context? + !!@instrument_server_context + end + def validate_tool_call_arguments? !!@validate_tool_call_arguments end @@ -152,6 +168,7 @@ module MCP protocol_version: protocol_version, validate_tool_call_arguments: validate_tool_call_arguments, validate_tool_call_results: validate_tool_call_results, + instrument_server_context: other.instrument_server_context?, ) end @@ -176,6 +193,12 @@ module MCP end end + def validate_value_of_instrument_server_context!(instrument_server_context) + unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass) + raise ArgumentError, "instrument_server_context must be a boolean" + end + end + def default_exception_reporter @default_exception_reporter ||= ->(exception, server_context) {} end diff --git a/lib/mcp/instrumentation.rb b/lib/mcp/instrumentation.rb index 9a1f434..5f1142f 100644 --- a/lib/mcp/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -7,6 +7,12 @@ module MCP begin @instrumentation_data = {} add_instrumentation_data(method: method) + # `self.` is required: the `server_context:` keyword above shadows the + # reader, and the value we want here is the user-defined hash passed to + # `Server.new`, not the per-call reporter context. + if configuration.instrument_server_context? && respond_to?(:server_context) + add_instrumentation_data(server_context: self.server_context) + end result = configuration.around_request.call(@instrumentation_data, &block) diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 86231f7..6aa10cc 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -145,6 +145,35 @@ module MCP refute merged.validate_tool_call_arguments end + test "defaults instrument_server_context to false" do + config = Configuration.new + + refute_predicate config, :instrument_server_context? + end + + test "accepts instrument_server_context in the constructor" do + config = Configuration.new(instrument_server_context: true) + + assert_predicate config, :instrument_server_context? + end + + test "raises ArgumentError when instrument_server_context is not a boolean value" do + config = Configuration.new + + exception = assert_raises(ArgumentError) do + config.instrument_server_context = "true" + end + assert_equal("instrument_server_context must be a boolean", exception.message) + end + + test "merge carries instrument_server_context from the other configuration" do + base = Configuration.new(instrument_server_context: true) + other = Configuration.new(instrument_server_context: false) + + refute_predicate base.merge(other), :instrument_server_context? + assert_predicate other.merge(base), :instrument_server_context? + end + test "defaults validate_tool_call_results to false" do config = Configuration.new refute config.validate_tool_call_results diff --git a/test/mcp/instrumentation_test.rb b/test/mcp/instrumentation_test.rb index ec2e3aa..8fb06af 100644 --- a/test/mcp/instrumentation_test.rb +++ b/test/mcp/instrumentation_test.rb @@ -40,6 +40,79 @@ module MCP end end + # Mirrors `MCP::Server`, which exposes the user-defined hash through + # `attr_accessor :server_context`. + class SubjectWithServerContext < Subject + attr_accessor :server_context + + def initialize(server_context) + super() + @server_context = server_context + end + end + + test "#instrument_call omits the user-defined server_context by default" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + + subject.instrumented_method + + refute_includes subject.instrumentation_data_received.keys, :server_context + end + + test "#instrument_call exposes the user-defined server_context when instrument_server_context is enabled" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) + end + + test "#instrument_call exposes the user-defined server_context to around_request" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + seen = nil + subject.configuration.around_request = ->(data, &handler) { + seen = data[:server_context] + handler.call + } + + subject.instrumented_method + + assert_equal({ user_id: 42 }, seen) + end + + test "#instrument_call reports the user-defined server_context as nil when the host does not set one" do + subject = SubjectWithServerContext.new(nil) + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + assert_includes subject.instrumentation_data_received.keys, :server_context + assert_nil subject.instrumentation_data_received[:server_context] + end + + test "#instrument_call does not confuse the reporter context with the user-defined server_context" do + # `instrument_call(server_context:)` is the exception-reporter context, a + # different value from the user-defined hash. Enabling the flag must + # surface the latter, not the former. + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + + subject.instrumented_method_with_server_context({ request: "reporter-context" }) + + assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) + end + + test "#instrument_call skips server_context for hosts without the reader even when enabled" do + subject = Subject.new + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + refute_includes subject.instrumentation_data_received.keys, :server_context + end + test "#instrument_call adds the method name to the instrumentation data" do subject = Subject.new From 6e2b7a1971b274c87d503aa63d61ae3f6158275d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 05:04:21 +0900 Subject: [PATCH 182/247] Let Handlers Return Multi Round-Trip `input_required` Results per SEP-2322 ## Motivation and Context SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release) replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids, with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns `resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`, `sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`, and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state. The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`); this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986) and the TypeScript SDK's umbrella 2026-07-28 work. - New `MCP::Server::InputRequiredResult` value object: construction validation (at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization, and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest` (url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires `sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}` declaration implies form support. - Handlers return the object through the existing paths: both branches of `call_tool_with_args` and `call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips output schema validation and the structured-content fallback for it (output schema validation would otherwise run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and SEP-2549 cache-hint stamping. - One central hook in the dispatch lambda gates and serializes the result, after the cancellation check so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error, because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set. - `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader. The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`), so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model. The server holds no memory between rounds: handlers re-run from the start on every retry (deterministic replay, as in the Python SDK). The 2026-07-28 conformance requirements score 14 `input-required-result-*` scenarios and the capability checks of `server-stateless`, and each drives a diagnostic fixture tool the server under test must define. The fixture defines the tools those scenarios call, mirroring the suite's TypeScript reference fixture (`examples/servers/typescript/everything-server.ts`) in tool names and `inputRequests` keys: - `test_input_required_result_elicitation` / `_sampling` / `_list_roots`: one embedded request each, answered on the retry round (also exercised by the missing-response, result-type, ignore-extra-params, and validate-input scenarios) - `test_input_required_result_request_state`: stamps an opaque `requestState` and completes only after it round-trips - `test_input_required_result_multiple_inputs`: all three embeddable methods in one result - `test_input_required_result_multi_round`: two elicitation rounds with per-round `requestState` - `test_input_required_result_capabilities`: builds `inputRequests` from the declared client capabilities, so a sampling-only client is never asked to elicit - prompt `test_input_required_result_prompt`: the non-tool MRTR flow over `prompts/get` - `test_missing_capability`: guards on `require_client_capability!(:sampling)`, giving the `server-stateless` capability checks a target that answers -32021 with HTTP 400 `test_input_required_result_tampered_state` is deliberately absent: rejecting a tampered `requestState` needs the sealing machinery of the request-state security change, and its fixture tool lands there. Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop follow in the next changes. Refs #382. ## How Has This Been Tested? New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing, the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations, and the implied-form back-compat rule. New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods, the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error, `-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`, and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints. Against the fixture server at `--spec-version 2026-07-28`, all SEP-2322 checks of the 13 covered scenarios pass, and `server-stateless` reports `sep-2575-server-rejects-undeclared-capability` and `sep-2575-missing-capability-http-400` as SUCCESS. The `wire-schema-valid` failures on rounds that reach a complete result come from the missing `resultType` stamp, which is the separate stamping change's job. The `--requirements 2025-11-25` server leg still passes 78/78, and `bundle exec rake` is green. ## Breaking Changes None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`, and requests that carry no `inputResponses`/`requestState` behave exactly as before. --- README.md | 8 + conformance/server.rb | 365 ++++++++++++++++++ lib/mcp/server.rb | 76 +++- lib/mcp/server/input_required_result.rb | 163 ++++++++ lib/mcp/server_context.rb | 20 +- test/mcp/server/input_required_result_test.rb | 109 ++++++ test/mcp/server_test.rb | 130 +++++++ 7 files changed, 864 insertions(+), 7 deletions(-) create mode 100644 lib/mcp/server/input_required_result.rb create mode 100644 test/mcp/server/input_required_result_test.rb diff --git a/README.md b/README.md index 13803a2..2779d85 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ It implements the Model Context Protocol specification, handling model context r and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result +- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that + opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for + additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request, + which the modern lifecycle forbids. On the retried request the handler re-runs from the start and reads the answers via + `server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` + (deterministic replay; the server holds no memory between rounds). The SDK rejects issuance on legacy requests and returns `-32021` + when an embedded request needs a client capability the request did not declare. Note that the echoed `requestState` arrives as + client-controlled input; treat it accordingly - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/conformance/server.rb b/conformance/server.rb index 7b697d8..256a1d4 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -2,6 +2,7 @@ require "rackup" require "json" +require "securerandom" require "set" require "uri" require_relative "../lib/mcp" @@ -13,6 +14,43 @@ module Conformance # Minimal WAV file (matches TypeScript SDK and Python SDK) BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" + # SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start, + # so every round reads the client's earlier answers back through these. + module Mrtr + module_function + + # The transport parses JSON with symbolized keys, but retries may also arrive through + # `Server#handle` with string keys; tolerate both like the SDK's own readers. + def read(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + + # The `content` of an accepted `elicitation/create` response, or `nil` for a declined, + # malformed, or missing one (a `nil` return re-requests the input). + def accepted_content(response) + content = read(response, :content) + content if read(response, :action) == "accept" && content.is_a?(Hash) + end + + # The `text` of a `sampling/createMessage` response, or `nil` when malformed. + def sampled_text(response) + text = read(read(response, :content), :text) + text if text.is_a?(String) + end + + def parse_state(request_state) + return unless request_state.is_a?(String) + + parsed = JSON.parse(request_state) + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + end + end + module Tools class TestSimpleText < MCP::Tool tool_name "test_simple_text" @@ -292,6 +330,285 @@ module Conformance end end end + + class TestMissingCapability < MCP::Tool + tool_name "test_missing_capability" + description "A tool that requires the sampling client capability (SEP-2575)" + + class << self + def call(server_context:, **_args) + server_context.require_client_capability!(:sampling) + + MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h]) + end + end + end + + class TestInputRequiredResultElicitation < MCP::Tool + tool_name "test_input_required_result_elicitation" + description "A tool that asks for the user's name through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + + unless name.is_a?(String) + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([MCP::Content::Text.new("Hello, #{name}!").to_h]) + end + end + end + + class TestInputRequiredResultSampling < MCP::Tool + tool_name "test_input_required_result_sampling" + description "A tool that asks for an LLM completion through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + text = Mrtr.sampled_text(server_context.input_response("sample_request")) + + unless text + return MCP::Server::InputRequiredResult.new( + input_requests: { + sample_request: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }], + maxTokens: 100, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h]) + end + end + end + + class TestInputRequiredResultListRoots < MCP::Tool + tool_name "test_input_required_result_list_roots" + description "A tool that asks for the client's roots through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + roots = Mrtr.read(server_context.input_response("roots_request"), :roots) + + unless roots.is_a?(Array) + return MCP::Server::InputRequiredResult.new( + input_requests: { roots_request: { method: "roots/list" } }, + ) + end + + uris = roots.filter_map { |root| Mrtr.read(root, :uri) } + MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h]) + end + end + end + + class TestInputRequiredResultRequestState < MCP::Tool + tool_name "test_input_required_result_request_state" + description "A tool whose input_required result carries an opaque requestState (SEP-2322)" + + class << self + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) + + if state && state["kind"] == "request-state" && confirmed + return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h]) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + confirm: { + method: "elicitation/create", + params: { + message: "Confirm to continue", + requestedSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }), + ) + end + end + end + + class TestInputRequiredResultMultipleInputs < MCP::Tool + tool_name "test_input_required_result_multiple_inputs" + description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + greeting = Mrtr.sampled_text(server_context.input_response("greeting")) + roots = Mrtr.read(server_context.input_response("client_roots"), :roots) + + if name.is_a?(String) && greeting && roots.is_a?(Array) + return MCP::Tool::Response.new( + [MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h], + ) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + greeting: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }], + maxTokens: 50, + }, + }, + client_roots: { method: "roots/list" }, + }, + request_state: JSON.generate({ kind: "multiple-inputs" }), + ) + end + end + end + + class TestInputRequiredResultMultiRound < MCP::Tool + tool_name "test_input_required_result_multi_round" + description "A tool that needs two elicitation rounds before completing (SEP-2322)" + + class << self + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + + case state && state["round"] + when 1 + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + return second_round(name) if name.is_a?(String) + when 2 + content = Mrtr.accepted_content(server_context.input_response("favorite_color")) + color = content && Mrtr.read(content, :color) + if color.is_a?(String) + return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h]) + end + end + + first_round + end + + private + + def first_round + MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + request_state: JSON.generate({ round: 1 }), + ) + end + + def second_round(name) + MCP::Server::InputRequiredResult.new( + input_requests: { + favorite_color: { + method: "elicitation/create", + params: { + message: "What is your favorite color?", + requestedSchema: { + type: "object", + properties: { color: { type: "string" } }, + required: ["color"], + }, + }, + }, + }, + request_state: JSON.generate({ round: 2, name: name }), + ) + end + end + end + + class TestInputRequiredResultCapabilities < MCP::Tool + tool_name "test_input_required_result_capabilities" + description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)" + + class << self + def call(server_context:, **_args) + capabilities = server_context.client_capabilities || {} + elicited = Mrtr.accepted_content(server_context.input_response("elicit_input")) + sampled = Mrtr.sampled_text(server_context.input_response("sample_input")) + + requests = {} + if Mrtr.read(capabilities, :elicitation) && !elicited + requests[:elicit_input] = { + method: "elicitation/create", + params: { + message: "Provide a value", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + }, + }, + } + end + if Mrtr.read(capabilities, :sampling) && !sampled + requests[:sample_input] = { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Say hello" } }], + maxTokens: 50, + }, + } + end + + if requests.empty? + MCP::Tool::Response.new( + [MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h], + ) + else + MCP::Server::InputRequiredResult.new(input_requests: requests) + end + end + end + end end module Prompts @@ -390,6 +707,45 @@ module Conformance end end end + + class TestInputRequiredResultPrompt < MCP::Prompt + prompt_name "test_input_required_result_prompt" + description "A prompt that asks for user context through an input_required result (SEP-2322)" + + class << self + def template(_args, server_context: nil) + content = server_context && Mrtr.accepted_content(server_context.input_response("user_context")) + context = content && Mrtr.read(content, :context) + + unless context.is_a?(String) + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_context: { + method: "elicitation/create", + params: { + message: "Provide context for the prompt", + requestedSchema: { + type: "object", + properties: { context: { type: "string" } }, + required: ["context"], + }, + }, + }, + }, + ) + end + + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Context: #{context}"), + ), + ], + ) + end + end + end end class Server @@ -482,12 +838,21 @@ module Conformance Tools::TestElicitationSep1034Defaults, Tools::TestElicitationSep1330Enums, Tools::TestReconnection, + Tools::TestMissingCapability, + Tools::TestInputRequiredResultElicitation, + Tools::TestInputRequiredResultSampling, + Tools::TestInputRequiredResultListRoots, + Tools::TestInputRequiredResultRequestState, + Tools::TestInputRequiredResultMultipleInputs, + Tools::TestInputRequiredResultMultiRound, + Tools::TestInputRequiredResultCapabilities, ], prompts: [ Prompts::TestSimplePrompt, Prompts::TestPromptWithArguments, Prompts::TestPromptWithEmbeddedResource, Prompts::TestPromptWithImage, + Prompts::TestInputRequiredResultPrompt, ], resources: resources, resource_templates: resource_templates, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index b8d3389..9ff09cc 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -10,6 +10,7 @@ require_relative "progress" require_relative "protocol_deprecations" require_relative "server_context" require_relative "server/capabilities" +require_relative "server/input_required_result" require_relative "server/pagination" require_relative "server/transports" @@ -587,7 +588,10 @@ module MCP when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)) + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + + # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints. + contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) @@ -611,6 +615,12 @@ module MCP next JsonRpcHandler::NO_RESPONSE end + # Runs after the cancellation check so a cancelled request stays suppressed + # instead of turning into a gate error response. + if result.is_a?(InputRequiredResult) + result = serialize_input_required_result(result, envelope: envelope, request: params) + end + result rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) @@ -669,6 +679,40 @@ module MCP end end + # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for + # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle, + # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as + # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on + # (or embed requests for) capabilities the client did not declare, and reports every missing capability at + # once so the client sees the full set. + def serialize_input_required_result(result, envelope:, request:) + if envelope.nil? + raise RequestHandlerError.new( + "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)", + request, + error_type: :internal_error, + ) + end + + missing = result.missing_client_capabilities(envelope.client_capabilities) + raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty? + + add_instrumentation_data(input_required: true) + result.to_h + end + + # Extracts the SEP-2322 retry fields a client sends when re-issuing a request: + # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`. + # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries. + def mrtr_retry_fields(params) + return { input_responses: nil, request_state: nil } unless params.is_a?(Hash) + + { + input_responses: params[:inputResponses] || params["inputResponses"], + request_state: params[:requestState] || params["requestState"], + } + end + def handle_cancelled_notification(params, session: nil) return unless session return unless params.is_a?(Hash) @@ -864,8 +908,21 @@ module MCP progress_token = request.dig(:_meta, :progressToken) response = call_tool_with_args( - tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope + tool, + arguments, + server_context_with_meta(request), + progress_token: progress_token, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + retry_fields: mrtr_retry_fields(request), ) + # An SEP-2322 `input_required` result is not a tool result: output schema + # validation would run against a `nil` `structuredContent` and the structured + # content fallback does not apply. The dispatch lambda serializes it. + return response if response.is_a?(InputRequiredResult) + result = response.to_h validate_tool_call_result!(tool, result) serialize_structured_content_fallback( @@ -1078,6 +1135,7 @@ module MCP meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + retry_fields = mrtr_retry_fields(meta_source) ServerContext.new( server_context_with_meta(meta_source), progress: progress, @@ -1085,6 +1143,8 @@ module MCP related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + input_responses: retry_fields[:input_responses], + request_state: retry_fields[:request_state], ) end @@ -1144,7 +1204,7 @@ module MCP end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil) # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. @@ -1160,6 +1220,8 @@ module MCP related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + input_responses: retry_fields&.fetch(:input_responses, nil), + request_state: retry_fields&.fetch(:request_state, nil), ) tool.call(**args, server_context: server_context) else @@ -1168,11 +1230,13 @@ module MCP end def call_prompt_template_with_args(prompt, args, server_context) - if accepts_server_context?(prompt.method(:template)) - prompt.template(args, server_context: server_context).to_h + raw_result = if accepts_server_context?(prompt.method(:template)) + prompt.template(args, server_context: server_context) else - prompt.template(args).to_h + prompt.template(args) end + + raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h end def server_context_with_meta(request) diff --git a/lib/mcp/server/input_required_result.rb b/lib/mcp/server/input_required_result.rb new file mode 100644 index 0000000..16242e7 --- /dev/null +++ b/lib/mcp/server/input_required_result.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../methods" +require_relative "../result_type" + +module MCP + class Server + # A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28). + # Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of + # their normal result to ask the client for additional input: `input_requests` maps server-assigned keys + # to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`), + # and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries + # the original request with `inputResponses` under the same keys. + # + # The server holds no memory between rounds: handlers re-run from the start on every retry and read + # the answers via `server_context.input_responses` / `server_context.request_state` + # (deterministic replay, matching the Python SDK). At least one of the two fields must be present; + # a `request_state`-only result is the load-shedding form ("retry later"). + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + class InputRequiredResult + EMBEDDABLE_METHODS = [ + Methods::ELICITATION_CREATE, + Methods::SAMPLING_CREATE_MESSAGE, + Methods::ROOTS_LIST, + ].freeze + + attr_reader :input_requests, :request_state + + def initialize(input_requests: nil, request_state: nil) + if (input_requests.nil? || input_requests.empty?) && request_state.nil? + raise ArgumentError, "at least one of input_requests or request_state is required" + end + unless request_state.nil? || request_state.is_a?(String) + raise ArgumentError, "request_state must be a String" + end + + @input_requests = normalize_input_requests(input_requests) + @request_state = request_state + freeze + end + + def to_h + serialized_requests = @input_requests&.transform_values do |entry| + { method: entry[:method], params: entry[:params] }.compact + end + + { + resultType: ResultType::INPUT_REQUIRED, + inputRequests: serialized_requests, + requestState: @request_state, + }.compact + end + + # The client capabilities required to fulfill every embedded request, merged into one nested hash. + # The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`: + # `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create` + # requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools` + # (plain sampling otherwise), and `roots/list` requires `roots`. + def required_client_capabilities + (@input_requests || {}).values.reduce({}) do |merged, entry| + deep_merge(merged, entry_capability(entry)) + end + end + + # The subset of {#required_client_capabilities} the request did not declare. + # Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared, + # so a non-empty return means the result must not be sent (`-32021`). + def missing_client_capabilities(declared) + missing = missing_subtree(required_client_capabilities, declared) + prune_implied_form_elicitation(missing, declared) + end + + private + + def normalize_input_requests(input_requests) + return if input_requests.nil? + + raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash) + + normalized = input_requests.each_with_object({}) do |(key, entry), result| + raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash) + + method = entry[:method] || entry["method"] + unless EMBEDDABLE_METHODS.include?(method) + raise ArgumentError, + "input_requests entry #{key.inspect} must have a method of " \ + "#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})" + end + + params = entry[:params] || entry["params"] + raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash) + + result[key.to_s] = { method: method, params: params }.compact.freeze + end + + normalized.freeze + end + + def entry_capability(entry) + params = entry[:params] + + case entry[:method] + when Methods::ELICITATION_CREATE + mode = params && (params[:mode] || params["mode"]) + mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } } + when Methods::SAMPLING_CREATE_MESSAGE + with_tools = params && (params.key?(:tools) || params.key?("tools") || + params.key?(:toolChoice) || params.key?("toolChoice")) + with_tools ? { sampling: { tools: {} } } : { sampling: {} } + when Methods::ROOTS_LIST + { roots: {} } + end + end + + # Walks `required` and keeps only the branches absent from `declared` + # (symbol/string tolerant on the declared side). + def missing_subtree(required, declared) + required.each_with_object({}) do |(name, nested), missing| + declared_value = read_key(declared, name) + + if declared_value.nil? + missing[name] = nested + elsif nested.is_a?(Hash) && !nested.empty? + nested_missing = missing_subtree(nested, declared_value) + missing[name] = nested_missing unless nested_missing.empty? + end + end + end + + # 2025 back-compat implication shared with the TypeScript and Python SDKs: + # a bare `elicitation: {}` declaration implies form elicitation support, while + # an explicit url-only declaration does not. + def prune_implied_form_elicitation(missing, declared) + elicitation_missing = missing[:elicitation] + return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form) + + declared_elicitation = read_key(declared, :elicitation) + return missing unless declared_elicitation.is_a?(Hash) + return missing unless read_key(declared_elicitation, :url).nil? + + pruned = elicitation_missing.reject { |key, _| key == :form } + pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned) + end + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + + def deep_merge(left, right) + left.merge(right) do |_key, left_value, right_value| + if left_value.is_a?(Hash) && right_value.is_a?(Hash) + deep_merge(left_value, right_value) + else + right_value + end + end + end + end + end +end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index d2abb52..0bbec5c 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -8,13 +8,31 @@ module MCP # `nil` on legacy requests. attr_reader :envelope - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil) + # SEP-2322 multi round-trip retry fields, present when the client re-issued the request after + # an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to + # the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly. + # Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them + # (the same access model as the envelope readers). + attr_reader :input_responses, :request_state + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, + input_responses: nil, request_state: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id @cancellation = cancellation @envelope = envelope + @input_responses = input_responses + @request_state = request_state + end + + # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys. + def input_response(key) + return unless @input_responses.is_a?(Hash) + + value = @input_responses[key.to_sym] + value.nil? ? @input_responses[key.to_s] : value end def cancelled? diff --git a/test/mcp/server/input_required_result_test.rb b/test/mcp/server/input_required_result_test.rb new file mode 100644 index 0000000..ba8ce43 --- /dev/null +++ b/test/mcp/server/input_required_result_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class InputRequiredResultTest < ActiveSupport::TestCase + test "requires at least one of input_requests or request_state" do + assert_raises(ArgumentError) { InputRequiredResult.new } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: {}) } + assert_raises(ArgumentError) { InputRequiredResult.new(request_state: 42) } + end + + test "validates input_requests entries" do + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: []) } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: { region: "not-a-hash" }) } + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "tools/call" } }) + end + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: "x" } }) + end + end + + test "normalizes keys, tolerates string entry keys, and freezes" do + result = InputRequiredResult.new(input_requests: { + region: { "method" => "elicitation/create", "params" => { message: "Which region?" } }, + }) + + assert_predicate result, :frozen? + assert_equal ["region"], result.input_requests.keys + assert_equal "elicitation/create", result.input_requests["region"][:method] + assert_equal({ message: "Which region?" }, result.input_requests["region"][:params]) + end + + test "#to_h serializes the SEP-2322 wire shape" do + result = InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "opaque-state", + ) + + assert_equal( + { + resultType: "input_required", + inputRequests: { "region" => { method: "elicitation/create", params: { message: "Which region?" } } }, + requestState: "opaque-state", + }, + result.to_h, + ) + end + + test "#to_h omits absent fields" do + state_only = InputRequiredResult.new(request_state: "opaque-state") + + assert_equal({ resultType: "input_required", requestState: "opaque-state" }, state_only.to_h) + end + + test "#required_client_capabilities maps every embedded request kind" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + url: { method: "elicitation/create", params: { mode: "url", url: "https://example.com" } }, + plain_sampling: { method: "sampling/createMessage", params: { messages: [] } }, + tool_sampling: { method: "sampling/createMessage", params: { messages: [], tools: [] } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { + elicitation: { form: {}, url: {} }, + sampling: { tools: {} }, + roots: {}, + }, + result.required_client_capabilities, + ) + end + + test "#missing_client_capabilities returns only undeclared branches" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { elicitation: { form: {} }, roots: {} }, + result.missing_client_capabilities({}), + ) + assert_equal( + { roots: {} }, + result.missing_client_capabilities({ elicitation: { form: {} } }), + ) + assert_empty result.missing_client_capabilities({ "elicitation" => { "form" => {} }, "roots" => {} }) + end + + test "#missing_client_capabilities treats a bare elicitation declaration as implying form" do + # 2025 back-compat rule shared with the TypeScript and Python SDKs; an explicit + # url-only declaration does not imply form support. + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + }) + + assert_empty result.missing_client_capabilities({ elicitation: {} }) + assert_equal( + { elicitation: { form: {} } }, + result.missing_client_capabilities({ elicitation: { url: {} } }), + ) + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 947817c..584f6ff 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -376,6 +376,136 @@ module MCP ) end + test "#handle tools/call serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.input_responses + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "state-1", + ) + end + end + + response = server.handle( + modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }), + ) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "state-1", result[:requestState] + assert_equal( + { method: "elicitation/create", params: { message: "Which region?" } }, + result.dig(:inputRequests, "region"), + ) + end + + test "#handle tools/call retry leg exposes input responses and request state to the handler" do + server = Server.new(name: "mrtr_test", tools: []) + seen = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + seen = { + input_responses: server_context.input_responses, + request_state: server_context.request_state, + region: server_context.input_response(:region), + } + Tool::Response.new([{ type: "text", text: "done" }]) + end + + request = modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } }, + requestState: "state-1", + }) + response = server.handle(request) + + refute_nil response[:result] + assert_equal "state-1", seen[:request_state] + assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region]) + assert_equal seen[:region], seen[:input_responses][:region] + end + + test "#handle rejects an input_required result on a legacy request with an internal error" do + # The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat + # an unknown resultType as a final result, so it must never reach them. + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "mrtr_tool" }, + }) + + assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code) + end + + test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(input_requests: { + region: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {})) + + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) + assert_equal( + { elicitation: { form: {} }, roots: {} }, + response.dig(:error, :data, :requiredCapabilities), + ) + end + + test "#handle tools/call input_required bypasses output schema validation" do + configuration = Configuration.new(validate_tool_call_results: true) + server = Server.new(name: "mrtr_test", tools: [], configuration: configuration) + server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" })) + + assert_equal "input_required", response.dig(:result, :resultType) + end + + test "#handle prompts/get serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test") + server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| + server_context.request_state # participates via server_context: + Server::InputRequiredResult.new(request_state: "prompt-state") + end + + response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} })) + + assert_equal "input_required", response.dig(:result, :resultType) + assert_equal "prompt-state", response.dig(:result, :requestState) + end + + test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do + server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public") + server.resources_read_handler do |_params, server_context:| + server_context.request_state + Server::InputRequiredResult.new(request_state: "resource-state") + end + + response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" })) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "resource-state", result[:requestState] + refute result.key?(:contents) + refute result.key?(:ttlMs) + refute result.key?(:cacheScope) + end + test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do session = ServerSession.new(server: @server, transport: mock) From 14bf3a709132edf70499b329b160d1cca326c78e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Jul 2026 02:24:20 +0900 Subject: [PATCH 183/247] Stamp the required `resultType` on modern results per SEP-2322 ## Motivation and Context SEP-2322 (merged for the 2026-07-28 spec release) makes `resultType` a REQUIRED member of every result a 2026-07-28 server returns: "Servers implementing this protocol version MUST include this field", with absence tolerated only when the result comes FROM an earlier protocol version. The client-side vocabulary landed earlier (`MCP::ResultType`, treating an absent value as `"complete"`), but every ordinary modern result the server returns is unstamped, which violates the MUST for `tools/list`, `tools/call`, `ping`, and every other standard result on the modern wire. The conformance suite's 2026-07-28 requirements fail each of those scenarios on wire-schema validation (`CallToolResult: must have required property 'resultType'`). The dispatch path now stamps `resultType: "complete"` centrally: any Hash result of a request carrying the SEP-2575 `_meta` envelope that does not already carry a `resultType` gets the standard value merged in. Results that already carry a discriminator keep it, notifications and cancellation-suppressed responses are untouched, and legacy results stay unstamped because pre-2026 clients do not know the field. `server/discover`, which is exempt from the `_meta` envelope (pre-version discovery), stamps its `DiscoverResult` at its construction site instead. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: `tools/list`, `ping`, and `tools/call` results carry `resultType: "complete"` on modern requests and no `resultType` on legacy requests; `server/discover` carries the stamp. The conformance scenarios previously failing on the missing property (`tools-call-simple-text`, `prompts-get-simple`, `completion-complete`, and the other tools/prompts scenarios) pass at `--spec-version 2026-07-28` against the conformance fixture server. ## Breaking Changes None. The stamp applies only to results of requests carrying the SEP-2575 modern `_meta` envelope; results on stable protocol versions through 2025-11-25 are byte-for-byte unchanged. --- README.md | 4 ++++ lib/mcp/server.rb | 16 +++++++++++++++- test/mcp/server_test.rb | 42 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2779d85..d9d93df 100644 --- a/README.md +++ b/README.md @@ -2851,6 +2851,10 @@ The Ruby client recognizes such results and raises `MCP::Client::InputRequiredEr and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. +SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 server returns. The server stamps `resultType: "complete"` on all results of requests carrying +the modern `_meta` envelope (and on `server/discover` results), while results that already carry a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. +Legacy results stay unstamped, and clients treat an absent `resultType` as `"complete"` per the spec. + ## Conformance Testing The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 9ff09cc..a169c87 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -621,6 +621,14 @@ module MCP result = serialize_input_required_result(result, envelope: envelope, request: params) end + # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns; + # a missing value is only tolerated FROM earlier protocol versions. Results that already carry + # a discriminator keep it; everything else on the modern wire is the standard shape, + # `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field. + if envelope && result.is_a?(Hash) && !result.key?(:resultType) + result = result.merge(resultType: ResultType::COMPLETE) + end + result rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) @@ -837,7 +845,13 @@ module MCP capabilities: capabilities, instructions: instructions, _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info }, - }.compact.merge(ttlMs: ttl_ms || 0, cacheScope: cache_scope || "private") + }.compact.merge( + ttlMs: ttl_ms || 0, + cacheScope: cache_scope || "private", + # `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it; + # `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly. + resultType: ResultType::COMPLETE, + ) end def configure_logging_level(request, session: nil) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 584f6ff..6dde54f 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -4142,6 +4142,42 @@ module MCP refute Apps.client_supports?(server.client_capabilities) end + # SEP-2322 makes `resultType` REQUIRED on every result of a 2026-07-28 server; + # `"complete"` is the standard shape, and legacy results stay unstamped. + test "modern results carry resultType complete" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + [ + modern_request(Methods::TOOLS_LIST, {}), + modern_request(Methods::PING, {}), + modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }), + ].each do |request| + response = server.handle(request) + assert_equal "complete", response.dig(:result, :resultType), "expected stamp for #{request[:method]}" + end + end + + test "legacy results carry no resultType" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + [ + { jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }, + { jsonrpc: "2.0", method: Methods::PING, id: 2 }, + { jsonrpc: "2.0", method: Methods::TOOLS_CALL, id: 3, params: { name: "result_type_tool", arguments: {} } }, + ].each do |request| + response = server.handle(request) + refute response[:result].key?(:resultType), "expected no stamp for #{request[:method]}" + end + end + + test "server/discover carries resultType complete" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + response = server.handle({ jsonrpc: "2.0", method: Methods::SERVER_DISCOVER, id: 1 }) + + assert_equal "complete", response.dig(:result, :resultType) + end + private # Builds a request carrying the SEP-2575 modern `_meta` envelope. @@ -4159,5 +4195,11 @@ module MCP ), } end + + def result_type_tool + @result_type_tool ||= Tool.define(name: "result_type_tool") do |**| + Tool::Response.new([{ type: "text", text: "ok" }]) + end + end end end From da2a7f80d758ad92ae9f6a4d9b16ed7c4aa1f848 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 05:05:10 +0900 Subject: [PATCH 184/247] Stream modern request notifications and honor the envelope `logLevel` per SEP-2575 ## Motivation and Context The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange, and two delivery requirements of that model were unimplemented: - Notifications a handler emits while serving a modern request (progress, log messages) had nowhere to go: the modern path answered with a single JSON object, and the ephemeral per-request session degraded every notification to non-delivery. The conformance requirement expects them as frames on the request's own response stream (`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28). - The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC, was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`. The changes wire both up: - `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session; `send_notification` routes into the sink ahead of the stateless guard. A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response; a request that emitted none keeps the single JSON exchange and its HTTP status ladder. - `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`, and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level: without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent, the safe direction, matching the Python SDK's `allowed_log_levels`. The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation` and requires that every frame on the request's response stream is a notification or the final response, and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel` and requires that no `notifications/message` reaches the stream. The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes; `test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate, which drops the notification for modern requests that did not authorize logging through `io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds: with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame ahead of the final response rather than being dropped. One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs: delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved and the conformance checks are satisfied, but long-running handlers get no real-time interleaving. The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications in proportion to client-supplied input cannot grow one request's memory without bound, and past the cap the notify helpers report `false`, the same non-delivery degradation they have on every other undeliverable path. Live streaming can follow without changing the wire shape. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover: a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying in place without notifications, log suppression without the envelope `logLevel`, log delivery with it, the modern-era session ignoring the server-wide level while honoring a per-request one, and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it. Against the conformance fixture server, the `tools-call-with-progress` progress check passes at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change), the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and `sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`, `test_logging_tool` streams its `notifications/message` frame ahead of the result, and with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None for stable protocol versions: legacy requests and their notification delivery are unchanged. Within the modern lifecycle, notifications become deliverable where they were previously dropped, and log messages without a per-request `logLevel` stop leaking through the server-wide level, as the specification requires. --- conformance/server.rb | 32 ++++ lib/mcp/server.rb | 10 ++ .../transports/streamable_http_transport.rb | 55 +++++- lib/mcp/server_session.rb | 10 +- .../streamable_http_transport_test.rb | 162 ++++++++++++++++++ test/mcp/server_notification_test.rb | 25 +++ 6 files changed, 286 insertions(+), 8 deletions(-) diff --git a/conformance/server.rb b/conformance/server.rb index 3205f5b..cfe4dc6 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -344,6 +344,21 @@ module Conformance end end + class TestStreamingElicitation < MCP::Tool + tool_name "test_streaming_elicitation" + description "A tool whose response stream carries only notifications, never independent requests (SEP-2575)" + + class << self + def call(server_context:, **_args) + # No independent server-to-client request is ever written to the response stream; + # the progress notification only goes out when the request carried a progressToken. + server_context.report_progress(50, total: 100) + + MCP::Tool::Response.new([MCP::Content::Text.new("Streaming complete").to_h]) + end + end + end + class TestInputRequiredResultElicitation < MCP::Tool tool_name "test_input_required_result_elicitation" description "A tool that asks for the user's name through an input_required result (SEP-2322)" @@ -609,6 +624,21 @@ module Conformance end end end + + class TestLoggingTool < MCP::Tool + tool_name "test_logging_tool" + description "A tool that logs only for requests opting in via the _meta logLevel (SEP-2575)" + + class << self + def call(server_context:, **_args) + # On modern requests the notification is dropped unless `io.modelcontextprotocol/logLevel` authorized it, + # so the call itself is unconditional. + server_context.notify_log_message(data: "Log level honored", level: "info", logger: "conformance") + + MCP::Tool::Response.new([MCP::Content::Text.new("Logging evaluated").to_h]) + end + end + end end module Prompts @@ -846,6 +876,8 @@ module Conformance Tools::TestInputRequiredResultMultipleInputs, Tools::TestInputRequiredResultMultiRound, Tools::TestInputRequiredResultCapabilities, + Tools::TestStreamingElicitation, + Tools::TestLoggingTool, ], prompts: [ Prompts::TestSimplePrompt, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a169c87..47afb05 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -584,6 +584,16 @@ module MCP exception_already_reported: ->(e) { reported_exception.equal?(e) }, ) do envelope = lift_request_envelope(params, method: method, session: session) + + # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle: + # it authorizes `notifications/message` for this request only (SEP-2575). Without it, + # `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level + # reads as absent, so delivery stays off (the safe direction), matching the Python SDK. + if envelope&.log_level && session.respond_to?(:configure_logging) + request_logging = LoggingMessageNotification.new(level: envelope.log_level) + session.configure_logging(request_logging) if request_logging.valid_level? + end + result = case method when Methods::INITIALIZE init(params, session: session) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4c19eac..306dea4 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -51,6 +51,13 @@ module MCP # the stack or amplify parse cost (complements the byte cap). MAX_JSON_NESTING = 64 + # Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them + # in memory until the handler returns, so a handler that emits notifications in proportion to + # client-supplied input would otherwise let one request grow memory without bound. + # Notifications past the cap are not delivered and the notify helpers report `false`, + # the same non-delivery degradation they have on every other undeliverable path. + MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000 + # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -107,6 +114,10 @@ module MCP @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} + # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits; + # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575). + @modern_request_sinks = {} + # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, # which does not apply to stateless mode since it retains no sessions. @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT) @@ -235,17 +246,30 @@ module MCP end def send_notification(method, params = nil, session_id: nil, related_request_id: nil) - # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising - # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from - # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. - return false if @stateless - notification = { jsonrpc: "2.0", method: method, } notification[:params] = params if params + # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers + # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response. + # Checked before the stateless guard, since modern requests are served in stateless deployments too. + # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as + # non-delivery (`false`), never handed to the legacy paths below. + sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] } + if sink + return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS + + sink << notification + return true + end + + # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising + # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from + # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. + return false if @stateless + if session_id deliver_targeted_notification(notification, session_id, related_request_id) else @@ -564,12 +588,29 @@ module MCP mismatch_error = validate_modern_headers(request, body, header_version) return mismatch_error if mismatch_error - response = @server.handle(body, session: modern_session) + session = modern_session + notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } + begin + response = @server.handle(body, session: session) + ensure + @mutex.synchronize { @modern_request_sinks.delete(session.session_id) } + end # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path. return handle_accepted if response.nil? - [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]] + # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of + # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder. + # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving + # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count. + # Live streaming can follow without changing the wire shape. + if notifications.empty? + [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]] + else + frames = notifications + [response] + sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join + [200, SSE_HEADERS.dup, [sse_body]] + end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) json_rpc_error_response( diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index dfb765e..cf45797 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -252,7 +252,15 @@ module MCP def notify_log_message(data:, level:, logger: nil, related_request_id: nil) @server.send(:warn_if_deprecated_protocol_feature, :logging, session: self, uplevel: 2) - effective_logging = @logging_message_notification || @server.logging_message_notification + # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member + # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level + # does not apply (SEP-2575). + effective_logging = if @era == :modern + @logging_message_notification + else + @logging_message_notification || @server.logging_message_notification + end + return unless effective_logging&.should_notify?(level) params = { "data" => data, "level" => level } diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 3b2029d..b9cebf5 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5418,6 +5418,168 @@ module MCP assert_equal({ "elicitation" => { "form" => {} } }, body.dig("error", "data", "requiredCapabilities")) end + test "modern POST streams handler notifications ahead of the final response" do + @server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100) + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "progress_tool", + arguments: {}, + _meta: { + progressToken: "tok-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + frames = response[2][0].split("\n\n").reject(&:empty?).map do |chunk| + JSON.parse(chunk.sub(/\Aevent: message\ndata: /, "")) + end + assert_equal "notifications/progress", frames.first["method"] + assert_equal "tok-1", frames.first.dig("params", "progressToken") + assert frames.last.key?("result"), "expected the final frame to carry the result" + assert_equal 1, frames.last["id"] + end + + test "modern POST bounds the buffered notifications per request" do + # The sink holds notifications in memory until the handler returns, so a handler + # emitting in proportion to client input must not grow one request without bound. + overflow = 5 + delivery_results = [] + @server.define_tool(name: "flood_tool") do |server_context:| + (StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + overflow).times do |i| + delivery_results << server_context.notify_log_message(data: "message #{i}", level: "info") + end + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "flood_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "info", + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + frames = response[2][0].split("\n\n").reject(&:empty?) + assert_equal StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + 1, frames.size + assert_equal overflow, delivery_results.count { |result| result == false } + end + + test "modern POST without handler notifications keeps the single JSON exchange" do + response = @transport.handle_request(modern_rack_request(modern_body("tools/list", {}))) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + end + + test "modern POST suppresses log notifications without the envelope logLevel" do + # SEP-2575: log delivery is authorized per request via the `_meta` logLevel; + # the server-wide level does not apply to modern requests. + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + @server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "logging_tool", arguments: {} }), + )) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + refute_includes response[2][0], "notifications/message" + end + + test "modern POST delivers log notifications when the envelope carries logLevel" do + @server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "logging_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "info", + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + assert_includes response[2][0], "notifications/message" + end + + test "modern POST treats an unrecognized envelope logLevel as absent" do + # Matching the Python SDK, an unrecognized level reads as absent: + # delivery stays off instead of erroring on every log call. + reported = [] + configuration = MCP::Configuration.new(exception_reporter: ->(e, _ctx) { reported << e }) + server = Server.new(name: "log_level_test", configuration: configuration) + transport = StreamableHTTPTransport.new(server, dns_rebinding_protection: false) + server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "logging_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "bogus", + }, + }, + }.to_json + + response = transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + refute_includes response[2][0], "notifications/message" + assert_empty reported + end + test "modern POST serves server/discover without an envelope" do response = @transport.handle_request(modern_rack_request( { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 55ece8a..9e51a03 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -170,6 +170,31 @@ module MCP assert_empty mock_transport.notifications end + test "ServerSession#notify_log_message on a modern-era session ignores the server-wide level" do + # SEP-2575: on the modern lifecycle, log delivery is authorized per request via + # the envelope's logLevel; the server-wide level MUST NOT apply. + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + session = ServerSession.new(server: server, transport: mock_transport, era: :modern) + + session.notify_log_message(data: { message: "test" }, level: "error") + + assert_empty mock_transport.notifications + end + + test "ServerSession#notify_log_message on a modern-era session honors a per-request level" do + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + session = ServerSession.new(server: server, transport: mock_transport, era: :modern) + session.configure_logging(MCP::LoggingMessageNotification.new(level: "info")) + + session.notify_log_message(data: { message: "test" }, level: "error") + + assert_equal 1, mock_transport.notifications.length + assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] + end + test "#notify_log_message sends notification with logger through transport" do @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") @server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger") From 90b8fd49c803e8eb65cba7953ea7676680c9c0e6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 15:43:16 +0900 Subject: [PATCH 185/247] Derive `Mcp-Name` from the body in the modern transport test helper ## Motivation and Context #490 and #492 merged independently green but broke each other on main: #492 made the modern path reject a name-bearing POST whose body carries a target name without `Mcp-Name` (-32020, HTTP 400) and taught the `modern_rack_request` helper to derive `Mcp-Method` from the body, while #490, merged in between, added five modern `tools/call` tests that build their requests through that helper and therefore send no `Mcp-Name`. Since the #492 merge every CI run on main fails those five tests with 400 where 200 is expected; the library behavior itself is correct. The helper now derives `Mcp-Name` the same way it derives `Mcp-Method`: from `params.name` or `params.uri` when the body's method is one of the name-bearing three. That is what a conforming client sends per the 2026-07-28 specification, and what the TypeScript SDK's client, the bundled `MCP::Client::HTTP`, and the conformance harness's standard headers all do, so tests built through the helper model a compliant client by default. `name_header: nil` omits the header for tests of the requirement itself, symmetric with `method_header: nil`, and an explicit `headers:` entry still overrides the derived value, which keeps the base64 mismatch tests exercising their divergent names. ## How Has This Been Tested? The five failing tests pass again without being touched, and the #492 requirement tests (missing `Mcp-Method`, missing `Mcp-Name`, header mismatches, base64 decoding) still pass: the transport test file reports 217 runs with zero failures. `bundle exec rake` is green, including RuboCop and the `--requirements 2025-11-25` conformance baseline. ## Breaking Changes None. The change is confined to a test helper; the shipped gem is untouched. --- .../streamable_http_transport_test.rb | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 237e6ee..bf94fab 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5357,6 +5357,7 @@ module MCP test "modern POST without the Mcp-Name header on a name-bearing method returns 400 with -32020" do response = @transport.handle_request(modern_rack_request( modern_body("tools/call", { name: "some_tool", arguments: {} }), + name_header: nil, )) assert_equal 400, response[0] @@ -5788,23 +5789,31 @@ module MCP end # Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header. - # Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST, - # so it defaults to the body's method. Pass `method_header: nil` to omit it + # Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST + # and `Mcp-Name` on a name-bearing method whose body carries a target name, so both default to + # the body's values. Pass `method_header: nil` / `name_header: nil` to omit one # (for tests of the requirement itself) or a String to send a specific value. - def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body) - derived_method = if method_header == :from_body - begin - parsed = JSON.parse(body) - parsed["method"] if parsed.is_a?(Hash) - rescue JSON::ParserError - nil + def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body, + name_header: :from_body) + parsed = begin + JSON.parse(body) + rescue JSON::ParserError + nil + end + parsed = nil unless parsed.is_a?(Hash) + + derived_method = method_header == :from_body ? parsed&.dig("method") : method_header + derived_name = if name_header == :from_body + if StreamableHTTPTransport::NAME_BEARING_METHODS.include?(parsed&.dig("method")) + parsed.dig("params", "name") || parsed.dig("params", "uri") end else - method_header + name_header end base_headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version } base_headers["HTTP_MCP_METHOD"] = derived_method if derived_method + base_headers["HTTP_MCP_NAME"] = derived_name if derived_name create_rack_request("POST", "/", base_headers.merge(headers), body) end From f39b1bf4aa5e9b01414c530ce04cfec9260a9fe0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 03:03:47 +0900 Subject: [PATCH 186/247] Align modern envelope validation with the finalized spec ## Motivation and Context Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation, both introduced against the frozen SEP text and overtaken by post-final spec changes: 1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only the `protocolVersion` + `clientCapabilities` pair. A conformant client configured not to identify itself was rejected. 2. A missing or mistyped envelope answered `-32600` Invalid Request. The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer with `-32602` naming the offending keys. Fixing these also corrects the era classification to match the reference SDKs: a request claims the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion` (the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks), and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without the claim key (`progressToken`, trace context) is classified exactly as before. Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK. ## How Has This Been Tested? `test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone, a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo` (reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities` and for mistyped required and optional fields. `test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is served with `server_context.client_info` reading `nil`, and a claim-less request on a modern-locked session answers `-32602` naming the required keys. The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths. ## Breaking Changes None for conforming clients. Requests that were already rejected change error code (`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests. Envelopes without `clientInfo`, previously rejected, now succeed. --- lib/mcp/request_envelope.rb | 45 +++++++++++------- lib/mcp/server.rb | 8 +++- test/mcp/request_envelope_test.rb | 46 ++++++++++++++++--- .../server/transports/stdio_transport_test.rb | 2 +- .../streamable_http_transport_test.rb | 2 +- test/mcp/server_test.rb | 37 +++++++++++++-- 6 files changed, 109 insertions(+), 31 deletions(-) diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb index bdf9c25..0a34549 100644 --- a/lib/mcp/request_envelope.rb +++ b/lib/mcp/request_envelope.rb @@ -2,10 +2,10 @@ module MCP # The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575). - # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version, - # client, and client capabilities through reserved `_meta` keys, and the server validates - # each request independently. Servers MUST NOT infer capabilities from prior requests, - # which is why the envelope is a per-request value object rather than session state. + # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version + # and client capabilities through reserved `_meta` keys (plus an optional client identity), + # and the server validates each request independently. Servers MUST NOT infer capabilities from + # prior requests, which is why the envelope is a per-request value object rather than session state. # # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 class RequestEnvelope @@ -22,26 +22,31 @@ module MCP # finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it. SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" + # `clientInfo` is deliberately absent: it became optional after the SEP was finalized + # (spec PR modelcontextprotocol/modelcontextprotocol#3002), so servers MUST accept + # envelopes without it. The TypeScript and Python SDKs validate the same required pair. REQUIRED_META_KEYS = [ PROTOCOL_VERSION_META_KEY, - CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY, ].freeze class << self - # A request is classified as modern only when the full REQUIRED triple is present, - # matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`. - # A partial triple is treated as legacy so existing `_meta` usage (`progressToken`, trace context) keeps - # flowing through the legacy path. + # A request claims the modern lifecycle when its `_meta` carries `io.modelcontextprotocol/protocolVersion`, + # matching the TypeScript SDK's envelope claim and the Python SDK's `_has_modern_envelope`. + # Classification is deliberately looser than validation: a claimed-but-malformed envelope is + # rejected by {parse!} with `-32602` instead of silently flowing through the legacy path, + # while `_meta` without the claim key (`progressToken`, trace context) stays legacy. def modern?(params) meta = extract_meta(params) return false unless meta.is_a?(Hash) - REQUIRED_META_KEYS.all? { |key| !read(meta, key).nil? } + !read(meta, PROTOCOL_VERSION_META_KEY).nil? end - # Parses and validates the envelope. `request` is only used to enrich the raised error; - # callers dispatching notifications can omit it. + # Parses and validates the envelope: `protocolVersion` and `clientCapabilities` are required, + # `clientInfo` is optional. A missing or mistyped field is Invalid params (`-32602`) naming + # the offending keys, the code and shape the spec mandates and the reference SDKs emit. + # `request` is only used to enrich the raised error; callers dispatching notifications can omit it. def parse!(params, request: nil) meta = extract_meta(params) meta = {} unless meta.is_a?(Hash) @@ -50,11 +55,17 @@ module MCP client_info = read(meta, CLIENT_INFO_META_KEY) client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY) - unless protocol_version.is_a?(String) && client_info.is_a?(Hash) && client_capabilities.is_a?(Hash) + invalid_keys = [] + invalid_keys << PROTOCOL_VERSION_META_KEY unless protocol_version.is_a?(String) + invalid_keys << CLIENT_CAPABILITIES_META_KEY unless client_capabilities.is_a?(Hash) + invalid_keys << CLIENT_INFO_META_KEY unless client_info.nil? || client_info.is_a?(Hash) + + unless invalid_keys.empty? raise Server::RequestHandlerError.new( - "Invalid Request: modern requests require `#{REQUIRED_META_KEYS.join("`, `")}` in `_meta`", + "Invalid params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`", request, - error_type: :invalid_request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, ) end @@ -87,9 +98,11 @@ module MCP end end + # `client_info` is `nil` when the client chose not to identify itself, which is legal: + # it is self-reported data and MUST NOT drive behavior or security decisions anyway. attr_reader :protocol_version, :client_info, :client_capabilities, :log_level - def initialize(protocol_version:, client_info:, client_capabilities:, log_level: nil) + def initialize(protocol_version:, client_capabilities:, client_info: nil, log_level: nil) @protocol_version = protocol_version @client_info = client_info @client_capabilities = client_capabilities diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 47afb05..262352f 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -689,10 +689,14 @@ module MCP RequestEnvelope.parse!(params, request: params) elsif era == :modern + # A claim-less request on a modern session is a malformed envelope, not a malformed request: + # the spec maps missing required envelope fields to Invalid params (`-32602`), + # and the reference SDKs answer it naming the missing keys. raise RequestHandlerError.new( - "Invalid Request: modern sessions require the SEP-2575 `_meta` envelope", + "Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`", params, - error_type: :invalid_request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, ) end end diff --git a/test/mcp/request_envelope_test.rb b/test/mcp/request_envelope_test.rb index e4c2548..e7261be 100644 --- a/test/mcp/request_envelope_test.rb +++ b/test/mcp/request_envelope_test.rb @@ -12,8 +12,9 @@ module MCP assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY end - test ".modern? returns true when the full required triple is present" do + test ".modern? returns true when the protocolVersion claim key is present" do assert RequestEnvelope.modern?(modern_params) + assert RequestEnvelope.modern?({ _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }) end test ".modern? returns true for string keys" do @@ -28,11 +29,13 @@ module MCP assert RequestEnvelope.modern?(params) end - test ".modern? returns false for a partial triple" do + test ".modern? stays true for a claimed but incomplete envelope" do + # A claimed envelope must be validated by `parse!` (`-32602`), never silently served as legacy, + # matching the TypeScript and Python classifiers. params = modern_params params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") - refute RequestEnvelope.modern?(params) + assert RequestEnvelope.modern?(params) end test ".modern? returns false for legacy _meta entries such as progressToken" do @@ -75,18 +78,34 @@ module MCP assert_equal "2025-11-25", error.error_data[:requested] end - test ".parse! raises an invalid request error when the triple is incomplete" do + test ".parse! accepts an envelope without the optional clientInfo" do + # `clientInfo` became optional after the SEP was finalized (spec PR #3002); + # a conformant client configured not to identify itself must not be rejected. params = modern_params params[:_meta].delete(:"io.modelcontextprotocol/clientInfo") + envelope = RequestEnvelope.parse!(params) + + assert_nil envelope.client_info + assert_equal "2026-07-28", envelope.protocol_version + assert_equal({ elicitation: {} }, envelope.client_capabilities) + end + + test ".parse! raises Invalid params naming the missing key when clientCapabilities is absent" do + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") + error = assert_raises(Server::RequestHandlerError) do RequestEnvelope.parse!(params) end - assert_equal :invalid_request, error.error_type + assert_equal :invalid_params, error.error_type + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code + assert_includes error.message, "io.modelcontextprotocol/clientCapabilities" + refute_includes error.message, "io.modelcontextprotocol/protocolVersion" end - test ".parse! raises an invalid request error when a triple member has the wrong type" do + test ".parse! raises Invalid params when a required field has the wrong type" do params = modern_params params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash" @@ -94,7 +113,20 @@ module MCP RequestEnvelope.parse!(params) end - assert_equal :invalid_request, error.error_type + assert_equal :invalid_params, error.error_type + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code + end + + test ".parse! raises Invalid params when the optional clientInfo has the wrong type" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/clientInfo"] = "not-a-hash" + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_params, error.error_type + assert_includes error.message, "io.modelcontextprotocol/clientInfo" end private diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 1437877..460ef16 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -617,7 +617,7 @@ module MCP ]) refute responses[0].key?(:error) - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code) end test "#send_request raises on a modern-locked session" do diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index bf94fab..cc43bad 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5425,7 +5425,7 @@ module MCP )) assert_equal 400, response[0] - assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) end test "modern POST maps a missing client capability to 400 with -32021" do diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 6dde54f..69d9b8c 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -295,7 +295,30 @@ module MCP assert_equal "2027-01-01", response.dig(:error, :data, :requested) end - test "#handle treats a partial modern triple as a legacy request" do + test "#handle rejects a claimed but incomplete envelope with -32602 naming the missing key" do + # A request carrying the `protocolVersion` claim key must be validated, never silently served as legacy: + # the spec maps missing required envelope fields to Invalid params, and the TypeScript and Python classifiers + # answer the same way. + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "modern_tool", + arguments: {}, + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities" + end + + test "#handle serves an envelope without the optional clientInfo" do + # `clientInfo` became optional after the SEP was finalized (spec PR #3002). server = Server.new(name: "modern_test", tools: []) received_context = nil server.define_tool(name: "modern_tool") do |server_context:| @@ -310,12 +333,17 @@ module MCP params: { name: "modern_tool", arguments: {}, - _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, + }, }, }) refute_nil response[:result] - refute_predicate received_context, :modern? + assert_predicate received_context, :modern? + assert_nil received_context.client_info + assert_equal({ elicitation: {} }, received_context.client_capabilities) end test "#handle requires the envelope for requests on a modern-locked session" do @@ -328,7 +356,8 @@ module MCP session: session, ) - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion" end test "#handle rejects initialize on a modern-locked session with -32022" do From 5932521552382c15fb90b9947b53e036b6e1de2f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 18:00:43 +0900 Subject: [PATCH 187/247] Pin the conformance CLI to the `alpha` dist-tag ## Motivation and Context The npm `latest` dist-tag of `@modelcontextprotocol/conformance` points at 0.1.16, the old 0.1 line, while the 2026-07-28 requirement revisions and the frozen-requirements framework live in the 0.2.0 alpha line (`alpha` currently resolves 0.2.0-alpha.11). The runners invoked the bare package name, so `rake conformance` and the conformance CI job resolve whatever `latest` points at: a fresh machine silently downgrades to the old suite while a warm npx cache may keep serving a newer one, and the suite actually in use depends on the environment. Pinning the `alpha` dist-tag puts every run on the line the frozen conformance requirements are anchored to (0.2.0-alpha.10, published 2026-07-27). Running the real alpha suite surfaced the actual client-leg state, so the expected-failures baseline is updated to match: `initialize` now passes (the harness accepts 2026-07-28, fulfilling the TODO on the old entry), and the scenarios pending client-side work are recorded with their reasons: the 2026-07-28 stateless lifecycle and the SEP-2322 MRTR driver, SEP-2243 custom-header mirroring, client-side JSON Schema preservation, the DPoP and WIF authorization extensions, and one `auth/authorization-server-migration` check added to the suite after the alpha.10 anchor. The manual `npx` examples in `conformance/README.md` carry the same pin so local reproduction runs the same suite as the rake task. ## How Has This Been Tested? `npx --yes @modelcontextprotocol/conformance@alpha --version` resolves 0.2.0-alpha.11, and `bundle exec rake conformance` passes the baseline check on both legs: the server leg reports 73 passed with 0 failed and no expected failures, and the client leg fails exactly the scenarios the baseline records. ## Breaking Changes None. The change is confined to the conformance tooling; the shipped gem is untouched. --- conformance/README.md | 4 ++-- conformance/client_runner.rb | 4 ++-- conformance/expected_failures.yml | 21 +++++++++++++++++---- conformance/server_runner.rb | 4 ++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 5179012..ea2c917 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -47,10 +47,10 @@ bundle exec rake conformance_server PORT=3000 bundle exec rake conformance_server # Terminal 2: run all scenarios -npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp +npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp # Terminal 2: run a single scenario -npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp --scenario ping +npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp --scenario ping ``` Keeps the server alive between test runs, which avoids the startup overhead when iterating diff --git a/conformance/client_runner.rb b/conformance/client_runner.rb index a99fbac..418e7f4 100644 --- a/conformance/client_runner.rb +++ b/conformance/client_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Runs `npx @modelcontextprotocol/conformance client` against the conformance client script. +# Runs `npx @modelcontextprotocol/conformance@alpha client` against the conformance client script. require "English" module Conformance @@ -29,7 +29,7 @@ module Conformance npx_command = [ "npx", "--yes", - "@modelcontextprotocol/conformance", + "@modelcontextprotocol/conformance@alpha", "client", "--command", "bundle exec ruby #{client_script}", diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 371853d..e26c20e 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,6 +1,19 @@ server: [] client: - # TODO: The conformance framework's client-initialization check pins the expected requested version to 2025-11-25, - # while this SDK's latest protocol version is 2026-07-28. Remove once the @modelcontextprotocol/conformance package - # accepts 2026-07-28. - - initialize + # Pending client-side support for the 2026-07-28 stateless lifecycle: the conformance client + # still connects through the legacy handshake and has no SEP-2322 MRTR driver yet. + - request-metadata + - sep-2322-client-request-state + # SEP-2243 client-side custom-header mirroring and invalid-tool header gating + # are not implemented in `MCP::Client::HTTP` yet. + - http-custom-headers + - http-invalid-tool-headers + # Client-side JSON Schema preservation gaps: `$ref` values must reach the caller unresolved. + - json-schema-ref-no-deref + - json-schema-2020-12-preservation + # Unimplemented authorization extensions. + - auth/dpop + - auth/dpop-nonce + - auth/wif-jwt-bearer + # Fails one check added to the suite after the 0.2.0-alpha.10 requirements anchor. + - auth/authorization-server-migration diff --git a/conformance/server_runner.rb b/conformance/server_runner.rb index 0a4e9e2..3decbad 100644 --- a/conformance/server_runner.rb +++ b/conformance/server_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it. +# Starts the conformance server and runs `npx @modelcontextprotocol/conformance@alpha` against it. require "English" require "net/http" require_relative "server" @@ -31,7 +31,7 @@ module Conformance def build_command expected_failures_yml = File.expand_path("expected_failures.yml", __dir__) - npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance", "server", "--url", "http://localhost:#{@port}/mcp"] + npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance@alpha", "server", "--url", "http://localhost:#{@port}/mcp"] npx_command += ["--scenario", @scenario] if @scenario npx_command += ["--spec-version", @spec_version] if @spec_version npx_command += ["--verbose"] if @verbose From 633a5fa5ef39b1dc4e792dea1f23b50ec3128aa3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 01:37:18 +0900 Subject: [PATCH 188/247] Enforce the modern lifecycle admission rules per SEP-2575 ## Motivation and Context The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle that the current implementation answers with the wrong signal: - The RPCs the modern lifecycle removes (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe`) were still dispatched, or answered incidental errors (`initialize` answered `-32022`, `resources/subscribe` a capability error); the requirement is `-32601` Method not found with HTTP 404. - `server/discover` was fully envelope-exempt, while the requirements validate its envelope like every other modern request. The per-request `_meta` validation itself (`-32602` naming the offending keys, optional `clientInfo`, era classification on the reserved `protocolVersion` key alone) landed separately in #491; this change builds on that shape and completes the admission rules: - `Server#handle_request` rejects `Methods::MODERN_REMOVED_METHODS` on a modern-era session with `-32601` before the capability check, since the method does not exist in that era at all. The Streamable HTTP modern path maps `-32601` to 404 and `-32602` to 400 through its existing status ladder. - The dual-era sniff treats an `initialize` carrying the modern envelope as modern traffic (a modern client naming a removed method) instead of the legacy-distinctive handshake, and `server/discover` keeps its envelope exemption outside the modern era only. The envelope-less rejection on a modern-locked session adopts #491's wire shape, `-32602` with the detailed message. One deliberate divergence from the Python SDK: its dual-era stream loop answers an `initialize` on a modern-locked connection with `-32022` carrying the supported-version list, while its HTTP path serves the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently, so this SDK answers `-32601` on every transport, stdio included, for uniform era semantics. The stdio era-lock tests used a modern `ping` as the era-distinctive request; they now use `tools/list`, since `ping` no longer exists in the modern lifecycle. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New and updated tests cover each admission rule on `Server#handle`, the Streamable HTTP modern path, and the stdio era lock (all five removed methods per transport, the envelope requirement on `server/discover`, and the envelope-carrying `initialize` routing). Against the conformance fixture server, every admission check of the `server-stateless` scenario passes at `--spec-version 2026-07-28`: the per-request `_meta` validation, removed-method 404, discovery, capability, and version-negotiation checks are all green, and the only remaining failures are the `subscriptions/listen` checks that belong to separate work. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy `_meta` (`progressToken`, trace context) keeps its legacy classification. Within the modern lifecycle, previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate. --- lib/mcp/methods.rb | 14 +++++ lib/mcp/server.rb | 21 +++++-- .../transports/streamable_http_transport.rb | 12 +++- .../server/transports/stdio_transport_test.rb | 42 +++++++++---- .../streamable_http_transport_test.rb | 63 +++++++++++++++++-- test/mcp/server_test.rb | 31 +++++---- 6 files changed, 144 insertions(+), 39 deletions(-) diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index d256ab1..f4c7df6 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -21,6 +21,20 @@ module MCP TOOLS_CALL = "tools/call" TOOLS_LIST = "tools/list" + # RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575): + # `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`, + # `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource + # subscription pair by the connectionless model, which leaves nothing to keep alive + # or subscribe on. A modern-era request naming one of these answers with `-32601` + # Method not found (HTTP 404 on Streamable HTTP). + MODERN_REMOVED_METHODS = [ + INITIALIZE, + PING, + LOGGING_SET_LEVEL, + RESOURCES_SUBSCRIBE, + RESOURCES_UNSUBSCRIBE, + ].freeze + ROOTS_LIST = "roots/list" SAMPLING_CREATE_MESSAGE = "sampling/createMessage" ELICITATION_CREATE = "elicitation/create" diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 262352f..df68ef7 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -561,6 +561,18 @@ module MCP return end + # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found + # there regardless of the envelope or the server's declared capabilities: in this era the method + # does not exist at all, which is why the check precedes `ensure_capability!`. + if session.respond_to?(:era) && session.era == :modern && Methods::MODERN_REMOVED_METHODS.include?(method) + raise RequestHandlerError.new( + "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)", + request, + error_type: :method_not_found, + error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + ) + end + begin Methods.ensure_capability!(method, capabilities) rescue Methods::MissingRequiredCapabilityError => e @@ -669,14 +681,13 @@ module MCP # an invalid request because a connection can never change eras. def lift_request_envelope(params, method:, session:) return if Methods.notification?(method) - return if method == Methods::SERVER_DISCOVER era = session.respond_to?(:era) ? session.era : nil - if era == :modern && method == Methods::INITIALIZE - requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil - raise UnsupportedProtocolVersionError.new(requested, params) - end + # Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities + # before any negotiation. Under the modern era every request carries the envelope, discovery included: + # the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575). + return if method == Methods::SERVER_DISCOVER && era != :modern if RequestEnvelope.modern?(params) if era == :legacy diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 434d55a..f49dba6 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -1041,10 +1041,16 @@ module MCP !MCP::Configuration.modern_protocol_version?(version) end - # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body is legacy-distinctive. - # Unparsable or non-object bodies go to the modern path, whose error responses cover them. + # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body + # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying + # the envelope comes from a modern client naming a method its lifecycle removed, so it stays + # on the modern path and answers with -32601/404 (SEP-2575). + # Unparsable or non-object bodies go to the modern path, whose error responses cover them. def legacy_handshake_body?(body_string) - initialize_request?(parse_request_body(body_string)) + body = parse_request_body(body_string) + return false unless initialize_request?(body) + + !RequestEnvelope.modern?(body[:params]) rescue InvalidJsonError false end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 460ef16..699bcb7 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -555,7 +555,7 @@ module MCP test "locks the legacy era on a successful initialize and rejects a later modern envelope" do responses = run_transport_session([ initialize_request(id: 1), - modern_ping_request(id: 2), + modern_tools_list_request(id: 2), ]) refute responses[0].key?(:error) @@ -568,7 +568,7 @@ module MCP # the legacy handshake locks `:legacy`, so a later modern envelope is still rejected. responses = run_transport_session([ initialize_request(id: 1, protocol_version: "2026-07-28"), - modern_ping_request(id: 2), + modern_tools_list_request(id: 2), ]) assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion) @@ -576,22 +576,23 @@ module MCP assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) end - test "locks the modern era on a successful server/discover and rejects a later initialize with -32022" do + test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do responses = run_transport_session([ { jsonrpc: "2.0", method: "server/discover", id: 1 }, initialize_request(id: 2), - modern_ping_request(id: 3), + modern_tools_list_request(id: 3), ]) assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) assert_equal :modern, session_era - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported) + # SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection + # answers it with Method not found. + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code) refute responses[2].key?(:error) end test "locks the modern era on a successful request carrying the modern envelope" do - responses = run_transport_session([modern_ping_request(id: 1)]) + responses = run_transport_session([modern_tools_list_request(id: 1)]) refute responses[0].key?(:error) assert_equal :modern, session_era @@ -601,7 +602,7 @@ module MCP # An unsupported envelope version fails with -32022, so the connection stays unlocked # and a legacy initialize can still succeed afterwards. responses = run_transport_session([ - modern_ping_request(id: 1, version: "2027-01-01"), + modern_tools_list_request(id: 1, version: "2027-01-01"), initialize_request(id: 2), ]) @@ -612,16 +613,29 @@ module MCP test "requires the modern envelope after a modern era lock" do responses = run_transport_session([ - modern_ping_request(id: 1), - { jsonrpc: "2.0", method: "ping", id: 2 }, + modern_tools_list_request(id: 1), + { jsonrpc: "2.0", method: "tools/list", id: 2 }, ]) refute responses[0].key?(:error) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code) end + test "answers removed lifecycle methods with -32601 after a modern era lock" do + removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index| + { jsonrpc: "2.0", method: method, id: index + 2 } + end + responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests) + + refute responses[0].key?(:error) + responses[1..].each_with_index do |response, index| + method = Methods::MODERN_REMOVED_METHODS[index] + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + end + end + test "#send_request raises on a modern-locked session" do - run_transport_session([modern_ping_request(id: 1)]) + run_transport_session([modern_tools_list_request(id: 1)]) error = assert_raises(RuntimeError) do @transport.send_request("roots/list") @@ -643,10 +657,12 @@ module MCP } end - def modern_ping_request(id:, version: "2026-07-28") + # `tools/list` stands in as the era-distinctive modern request: `ping` cannot, + # because SEP-2575 removes it from the modern lifecycle (-32601 there). + def modern_tools_list_request(id:, version: "2026-07-28") { jsonrpc: "2.0", - method: "ping", + method: "tools/list", id: id, params: { _meta: { diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index cc43bad..6edce06 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5421,13 +5421,57 @@ module MCP test "modern POST requires the _meta envelope" do response = @transport.handle_request(modern_rack_request( - { jsonrpc: "2.0", method: "ping", id: 1 }.to_json, + { jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json, )) assert_equal 400, response[0] assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) end + test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do + body = { + jsonrpc: "2.0", + method: "tools/list", + id: 1, + params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST serves an envelope without the optional clientInfo" do + body = { + jsonrpc: "2.0", + method: "tools/list", + id: 1, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert JSON.parse(response[2][0]).key?("result") + end + + test "modern POST answers removed lifecycle methods with 404 and -32601" do + MCP::Methods::MODERN_REMOVED_METHODS.each do |method| + response = @transport.handle_request(modern_rack_request(modern_body(method, {}))) + + assert_equal 404, response[0], "expected 404 for #{method}" + body = JSON.parse(response[2][0]) + assert_equal(-32601, body.dig("error", "code"), "expected -32601 for #{method}") + assert_equal 1, body["id"], "expected the request id to be preserved for #{method}" + end + end + test "modern POST maps a missing client capability to 400 with -32021" do @server.define_tool(name: "guarded_tool") do |server_context:| server_context.require_client_capability!(:elicitation, :form) @@ -5607,10 +5651,8 @@ module MCP assert_empty reported end - test "modern POST serves server/discover without an envelope" do - response = @transport.handle_request(modern_rack_request( - { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, - )) + test "modern POST serves server/discover carrying the envelope" do + response = @transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) assert_equal 200, response[0] refute response[1].key?("mcp-session-id") @@ -5618,6 +5660,17 @@ module MCP assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") end + test "modern POST rejects an envelope-less server/discover with 400 and -32602" do + # The 2026-07-28 conformance requirements validate the `_meta` envelope on `server/discover` like + # on every other modern request (SEP-2575). + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + test "legacy POST rejects a modern envelope body without the modern header as -32020" do # Without header-primary routing evidence, the body-level triple would silently fall through # the legacy path via the header default. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 69d9b8c..fa99de6 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -360,28 +360,33 @@ module MCP assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion" end - test "#handle rejects initialize on a modern-locked session with -32022" do + test "#handle rejects removed lifecycle methods on a modern-locked session with -32601" do session = ServerSession.new(server: @server, transport: mock, era: :modern) - response = @server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } }, - }, - session: session, - ) + Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index| + response = @server.handle( + { jsonrpc: "2.0", method: method, id: index + 1 }, + session: session, + ) - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) - assert_equal "2025-11-25", response.dig(:error, :data, :requested) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}" + end end - test "#handle server/discover succeeds without an envelope on a modern-locked session" do + test "#handle server/discover requires the envelope on a modern-locked session" do + # The 2026-07-28 conformance requirements reject an envelope-less `server/discover` + # on the modern era with -32602 (SEP-2575). session = ServerSession.new(server: @server, transport: mock, era: :modern) response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + end + + test "#handle server/discover succeeds without an envelope outside the modern era" do + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + refute_nil response[:result] end From e6b5a9b5e3c451bed575a3f76404461c31f2af26 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 14:40:20 +0900 Subject: [PATCH 189/247] Add opt-in `requestState` sealing via `RequestStateSecurity` ## Motivation and Context The SEP-2322 `requestState` continuation string leaves the server, sits in the client's hands, and comes back as client-controlled input. Without protection a client can read the server's continuation state, tamper with it (for example forging an already-answered elicitation), or replay a state issued for one call against another tool, other arguments, or another server. The multi-round-trip documentation of the Python SDK is explicit that the echo must be treated as untrusted input; its high-level server seals the state by default via `RequestStateBoundary`. New `MCP::Server::RequestStateSecurity` (OpenSSL standard library only) brings that protection to this SDK as an opt-in: - `seal` encrypts the plaintext state with AES-256-GCM (clients cannot read it, not merely verify it) inside a claims envelope binding an expiry window (`ttl:`, default 300 seconds, re-sealed each round), the originating method and target (tool/prompt name or resource URI), a digest of the originating arguments (stringified and sorted recursively, so symbol/string parses of identical JSON digest identically), and an optional `audience:`. The token format is `v1.` with the version prefix bound as GCM associated data, following the Python SDK's `AESGCMRequestStateCodec`. - `unseal` verifies every claim fail-closed and raises `InvalidStateError` on tampering, expiry, or any mismatch. Passing an instance via `Server.new(request_state_security:)` makes the seal/unseal transparent: issuance seals the `requestState` of an outgoing `input_required` result, and dispatch unseals the echoed token for `tools/call`, `prompts/get`, and `resources/read` before any handler runs, so `server_context.request_state` always reads the plaintext the handler wrote. A tampered, expired, or cross-call echo is rejected as `-32602` with "Invalid or expired requestState", matching the Python SDK's frozen error. Without the option the state crosses the wire exactly as the handler wrote it (the Python low-level Server behavior); the README documents that this is then the handler author's responsibility, and that multi-process deployments must share the key across workers. The conformance fixture opts in with a random per-boot key, both to dogfood the feature and because the tampered-state scenario of the 2026-07-28 conformance requirements retries an MRTR request with a corrupted `requestState` and requires a JSON-RPC error; without integrity-checked state the fixture cannot detect the corruption. Every MRTR round trip completes within one server process, so no key persistence is needed. Sealing is transparent to the fixture tools, whose `requestState` plaintext round-trips unchanged, and the new `test_input_required_result_tampered_state` tool is therefore a plain MRTR elicitation flow; the tampered echo is rejected with `-32602` by the server-level unsealing before dispatch ever reaches it. Refs #382. ## How Has This Been Tested? New `test/mcp/server/request_state_security_test.rb` covers constructor validation, the seal/unseal round trip (opaque `v1.` token, plaintext not visible), expiry via time travel, fail-closed rejection of method/target/digest/audience mismatches, tampered and malformed tokens, and tokens sealed under a different key. New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: transparent sealing on issuance and plaintext restoration on the retry leg, `-32602` for tampered echoes and for echoes replayed against different arguments, and the pass-through default when `request_state_security:` is not set. Against the fixture server at `--spec-version 2026-07-28` (run with `@modelcontextprotocol/conformance@alpha`; the 2026-07-28 scenarios are not yet in a stable conformance release), `sep-2322-reject-tampered-state` reports SUCCESS, and the other 13 `input-required-result-*` scenarios stay green with sealing active, confirming that sealed states round-trip through the suite's retries without false rejections (the seal binds `method`, target, and an arguments digest, and the suite echoes all three unchanged). The `--requirements 2025-11-25` server leg still passes. ## Breaking Changes None. The feature is opt-in via a new keyword argument that defaults to `nil`, in which case behavior is byte-identical to before. --- README.md | 7 +- conformance/server.rb | 39 ++++++ lib/mcp/server.rb | 82 ++++++++++- lib/mcp/server/request_state_security.rb | 131 ++++++++++++++++++ .../mcp/server/request_state_security_test.rb | 90 ++++++++++++ test/mcp/server_test.rb | 74 ++++++++++ 6 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 lib/mcp/server/request_state_security.rb create mode 100644 test/mcp/server/request_state_security_test.rb diff --git a/README.md b/README.md index f83cdaa..d563a54 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,11 @@ It implements the Model Context Protocol specification, handling model context r which the modern lifecycle forbids. On the retried request the handler re-runs from the start and reads the answers via `server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` (deterministic replay; the server holds no memory between rounds). The SDK rejects issuance on legacy requests and returns `-32021` - when an embedded request needs a client capability the request did not declare. Note that the echoed `requestState` arrives as - client-controlled input; treat it accordingly + when an embedded request needs a client capability the request did not declare. The echoed `requestState` arrives as + client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to + have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers. + Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as + the handler wrote it and protecting it is the handler author's responsibility - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/conformance/server.rb b/conformance/server.rb index dca807d..ef138d0 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -575,6 +575,40 @@ module Conformance end end + class TestInputRequiredResultTamperedState < MCP::Tool + tool_name "test_input_required_result_tampered_state" + description "A tool whose sealed requestState rejects tampered echoes (SEP-2322)" + + class << self + # A tampered `requestState` never reaches this handler: the server-level unsealing rejects it with -32602 + # before dispatch, so the tool body is a plain MRTR flow. + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) + + if state && state["kind"] == "tamper-check" && confirmed + return MCP::Tool::Response.new([MCP::Content::Text.new("requestState integrity verified").to_h]) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + confirm: { + method: "elicitation/create", + params: { + message: "Confirm to continue", + requestedSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + request_state: JSON.generate({ kind: "tamper-check", nonce: SecureRandom.hex(8) }), + ) + end + end + end + class TestInputRequiredResultCapabilities < MCP::Tool tool_name "test_input_required_result_capabilities" description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)" @@ -869,6 +903,7 @@ module Conformance Tools::TestInputRequiredResultRequestState, Tools::TestInputRequiredResultMultipleInputs, Tools::TestInputRequiredResultMultiRound, + Tools::TestInputRequiredResultTamperedState, Tools::TestInputRequiredResultCapabilities, Tools::TestStreamingElicitation, Tools::TestLoggingTool, @@ -882,6 +917,10 @@ module Conformance ], resources: resources, resource_templates: resource_templates, + # A per-boot random key is enough for conformance: every MRTR round trip completes + # within one server process, and sealing keeps the fixture's `requestState` values + # tamper-evident without any tool-level verification code. + request_state_security: MCP::Server::RequestStateSecurity.new(key: SecureRandom.bytes(32)), capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 262352f..eaca0de 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -12,6 +12,7 @@ require_relative "server_context" require_relative "server/capabilities" require_relative "server/input_required_result" require_relative "server/pagination" +require_relative "server/request_state_security" require_relative "server/transports" module MCP @@ -145,7 +146,7 @@ module MCP CACHE_SCOPES = ["public", "private"].freeze attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope + attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security def initialize( description: nil, @@ -165,6 +166,7 @@ module MCP page_size: nil, ttl_ms: nil, cache_scope: nil, + request_state_security: nil, transport: nil ) @description = description @@ -184,6 +186,7 @@ module MCP self.page_size = page_size self.ttl_ms = ttl_ms self.cache_scope = cache_scope + @request_state_security = request_state_security @configuration = MCP.configuration.merge(configuration) @client = nil @client_protocol_version = nil @@ -594,6 +597,8 @@ module MCP session.configure_logging(request_logging) if request_logging.valid_level? end + params = unseal_request_state(params, method: method) if @request_state_security + result = case method when Methods::INITIALIZE init(params, session: session) @@ -628,7 +633,7 @@ module MCP # Runs after the cancellation check so a cancelled request stays suppressed # instead of turning into a gate error response. if result.is_a?(InputRequiredResult) - result = serialize_input_required_result(result, envelope: envelope, request: params) + result = serialize_input_required_result(result, envelope: envelope, request: params, method: method) end # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns; @@ -707,7 +712,7 @@ module MCP # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on # (or embed requests for) capabilities the client did not declare, and reports every missing capability at # once so the client sees the full set. - def serialize_input_required_result(result, envelope:, request:) + def serialize_input_required_result(result, envelope:, request:, method:) if envelope.nil? raise RequestHandlerError.new( "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)", @@ -720,7 +725,76 @@ module MCP raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty? add_instrumentation_data(input_required: true) - result.to_h + serialized = result.to_h + + if @request_state_security && serialized[:requestState] + serialized = serialized.merge(requestState: @request_state_security.seal( + serialized[:requestState], + method: method, + target: mrtr_target(request), + arguments_digest: mrtr_arguments_digest(request), + )) + end + + serialized + end + + # Methods whose results may be `input_required` and whose retried requests carry + # `inputResponses`/`requestState` (SEP-2322). + MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze + + # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch, + # so handlers always read the state they wrote. A tampered, expired, or cross-request token is + # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior. + def unseal_request_state(params, method:) + return params unless MRTR_METHODS.include?(method) + return params unless params.is_a?(Hash) + + sealed = params[:requestState] || params["requestState"] + return params unless sealed + + plaintext = @request_state_security.unseal( + sealed, + method: method, + target: mrtr_target(params), + arguments_digest: mrtr_arguments_digest(params), + ) + key = params.key?("requestState") ? "requestState" : :requestState + params.merge(key => plaintext) + rescue RequestStateSecurity::InvalidStateError => e + raise RequestHandlerError.new( + "Invalid or expired requestState", + params, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + original_error: e, + ) + end + + def mrtr_target(params) + return "" unless params.is_a?(Hash) + + params[:name] || params["name"] || params[:uri] || params["uri"] || "" + end + + # Digest of the originating arguments, binding a sealed state to retries of + # the same call with the same inputs. Keys are stringified and sorted recursively + # so symbol/string parses of identical JSON digest identically. + def mrtr_arguments_digest(params) + arguments = params.is_a?(Hash) ? params[:arguments] || params["arguments"] : nil + OpenSSL::Digest::SHA256.hexdigest(canonical_json(arguments || {})) + end + + def canonical_json(value) + case value + when Hash + pairs = value.map { |key, nested| [key.to_s, nested] }.sort_by(&:first) + "{#{pairs.map { |key, nested| "#{key.to_json}:#{canonical_json(nested)}" }.join(",")}}" + when Array + "[#{value.map { |element| canonical_json(element) }.join(",")}]" + else + value.to_json + end end # Extracts the SEP-2322 retry fields a client sends when re-issuing a request: diff --git a/lib/mcp/server/request_state_security.rb b/lib/mcp/server/request_state_security.rb new file mode 100644 index 0000000..bc96a85 --- /dev/null +++ b/lib/mcp/server/request_state_security.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "json" +require "openssl" + +module MCP + class Server + # Opt-in protection for the SEP-2322 `requestState` echo. The opaque continuation string leaves the server, + # sits in the client's hands, and comes back as client-controlled input, so it must be treated like + # any other untrusted data. Sealing encrypts the state with AES-256-GCM (clients cannot read it) and binds + # a claims envelope that unsealing verifies fail-closed: + # + # - `exp`: a TTL window (re-sealed each round) + # - `m` / `t`: the originating method and target (tool/prompt name or resource URI) + # - `a`: a digest of the originating arguments, so the state only resumes the same call with the same inputs + # - `aud`: an optional audience, so tokens cannot cross servers sharing a key + # + # Pass an instance via `Server.new(request_state_security:)` and the seal/unseal happens transparently; + # handlers keep reading plaintext through `server_context.request_state`. Without it, the state crosses + # the wire exactly as the handler wrote it (the author's responsibility, matching the Python SDK's low-level Server). + # The key must be shared across workers in multi-process + # + # deployments; a per-process random key makes retries that land on another worker fail with an invalid-state error, + # forcing clients to restart the flow. + # + # The token format is `v1.`, with the version prefix bound as GCM associated data, + # following the Python SDK's `AESGCMRequestStateCodec`. + class RequestStateSecurity + class InvalidStateError < StandardError; end + + VERSION_PREFIX = "v1." + KEY_BYTES = 32 + IV_BYTES = 12 + TAG_BYTES = 16 + DEFAULT_TTL = 300 + + def initialize(key:, ttl: DEFAULT_TTL, audience: nil) + unless key.is_a?(String) && key.bytesize == KEY_BYTES + raise ArgumentError, "key must be a #{KEY_BYTES}-byte String" + end + unless ttl.is_a?(Numeric) && ttl.positive? + raise ArgumentError, "ttl must be a positive number of seconds" + end + + @key = key.dup.force_encoding(Encoding::BINARY).freeze + @ttl = ttl + @audience = audience + end + + # Seals a plaintext state into an opaque token bound to the originating request. + def seal(state, method:, target:, arguments_digest:) + claims = { + v: 1, + exp: Time.now.to_i + @ttl, + m: method, + t: target, + a: arguments_digest, + aud: @audience, + s: state, + }.compact + + cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt + cipher.key = @key + iv = cipher.random_iv + cipher.auth_data = VERSION_PREFIX + ciphertext = cipher.update(JSON.generate(claims)) + cipher.final + + VERSION_PREFIX + base64url_encode(iv + ciphertext + cipher.auth_tag) + end + + # Unseals a client-echoed token and verifies every claim, failing closed with + # `InvalidStateError` on tampering, expiry, or a claims mismatch. + def unseal(sealed, method:, target:, arguments_digest:) + unless sealed.is_a?(String) && sealed.start_with?(VERSION_PREFIX) + raise InvalidStateError, "malformed token" + end + + blob = base64url_decode(sealed.delete_prefix(VERSION_PREFIX)) + raise InvalidStateError, "malformed token" if blob.bytesize < IV_BYTES + TAG_BYTES + + iv = blob.byteslice(0, IV_BYTES) + tag = blob.byteslice(-TAG_BYTES, TAG_BYTES) + ciphertext = blob.byteslice(IV_BYTES, blob.bytesize - IV_BYTES - TAG_BYTES) + + cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt + cipher.key = @key + cipher.iv = iv + cipher.auth_tag = tag + cipher.auth_data = VERSION_PREFIX + plaintext = begin + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError + raise InvalidStateError, "authentication failed" + end + + claims = begin + JSON.parse(plaintext, symbolize_names: true) + rescue JSON::ParserError + raise InvalidStateError, "malformed claims" + end + + verify!(claims, method: method, target: target, arguments_digest: arguments_digest) + claims[:s] + end + + private + + def verify!(claims, method:, target:, arguments_digest:) + raise InvalidStateError, "unsupported version" unless claims[:v] == 1 + raise InvalidStateError, "expired" unless claims[:exp].is_a?(Integer) && Time.now.to_i <= claims[:exp] + raise InvalidStateError, "method mismatch" unless claims[:m] == method + raise InvalidStateError, "target mismatch" unless claims[:t] == target + raise InvalidStateError, "arguments mismatch" unless claims[:a] == arguments_digest + raise InvalidStateError, "audience mismatch" unless claims[:aud] == @audience + raise InvalidStateError, "missing state" unless claims[:s].is_a?(String) + end + + def base64url_encode(data) + [data].pack("m0").tr("+/", "-_").delete("=") + end + + def base64url_decode(encoded) + padded = encoded.tr("-_", "+/") + padded += "=" * ((4 - padded.length % 4) % 4) + padded.unpack1("m0") + rescue ArgumentError + raise InvalidStateError, "malformed token" + end + end + end +end diff --git a/test/mcp/server/request_state_security_test.rb b/test/mcp/server/request_state_security_test.rb new file mode 100644 index 0000000..29b7d79 --- /dev/null +++ b/test/mcp/server/request_state_security_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class RequestStateSecurityTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + + KEY = ("k" * 32).freeze + + test "validates constructor arguments" do + assert_raises(ArgumentError) { RequestStateSecurity.new(key: "short") } + assert_raises(ArgumentError) { RequestStateSecurity.new(key: KEY, ttl: 0) } + end + + test "seals and unseals a state bound to the originating request" do + security = RequestStateSecurity.new(key: KEY, audience: "my_server") + sealed = security.seal("plain-state", method: "tools/call", target: "my_tool", arguments_digest: "digest") + + assert sealed.start_with?("v1.") + refute_includes sealed, "plain-state" + assert_equal "plain-state", + security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") + end + + test "rejects expired tokens" do + security = RequestStateSecurity.new(key: KEY, ttl: 60) + sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") + + travel 120 do + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") + end + end + end + + test "rejects claims mismatches fail-closed" do + security = RequestStateSecurity.new(key: KEY, audience: "my_server") + sealed = security.seal("state", method: "tools/call", target: "my_tool", arguments_digest: "digest") + + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "prompts/get", target: "my_tool", arguments_digest: "digest") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "other_tool", arguments_digest: "digest") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "other") + end + + other_audience = RequestStateSecurity.new(key: KEY, audience: "other_server") + assert_raises(RequestStateSecurity::InvalidStateError) do + other_audience.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") + end + end + + test "rejects tampered and malformed tokens" do + security = RequestStateSecurity.new(key: KEY) + sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") + + tampered = sealed.dup + tampered[-1] = tampered[-1] == "A" ? "B" : "A" + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(tampered, method: "tools/call", target: "t", arguments_digest: "d") + end + + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal("not-a-token", method: "tools/call", target: "t", arguments_digest: "d") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal("v1.!!!", method: "tools/call", target: "t", arguments_digest: "d") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(nil, method: "tools/call", target: "t", arguments_digest: "d") + end + end + + test "rejects tokens sealed under a different key" do + sealed = RequestStateSecurity.new(key: KEY) + .seal("state", method: "tools/call", target: "t", arguments_digest: "d") + other = RequestStateSecurity.new(key: "x" * 32) + + assert_raises(RequestStateSecurity::InvalidStateError) do + other.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") + end + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 69d9b8c..162bdfb 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -505,6 +505,80 @@ module MCP assert_equal "input_required", response.dig(:result, :resultType) end + test "#handle seals and unseals requestState transparently when request_state_security is set" do + security = Server::RequestStateSecurity.new(key: "k" * 32) + server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) + seen_state = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.request_state + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new(request_state: "plain-state") + end + end + + first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) + sealed = first.dig(:result, :requestState) + + assert sealed.start_with?("v1.") + refute_equal "plain-state", sealed + + retry_response = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + requestState: sealed, + })) + + refute_nil retry_response[:result] + # The handler reads the plaintext it originally wrote. + assert_equal "plain-state", seen_state + end + + test "#handle rejects a tampered or cross-call requestState echo with -32602" do + security = Server::RequestStateSecurity.new(key: "k" * 32) + server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new(request_state: "plain-state") + end + end + + first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) + sealed = first.dig(:result, :requestState) + + tampered = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + requestState: sealed.sub("v1.", "v1.x"), + })) + # Different arguments than the sealing call: the digest claim no longer matches. + cross_call = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: { other: true }, + requestState: sealed, + })) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, tampered.dig(:error, :code) + assert_equal "Invalid or expired requestState", tampered.dig(:error, :message) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, cross_call.dig(:error, :code) + end + + test "#handle passes requestState through untouched without request_state_security" do + server = Server.new(name: "mrtr_test", tools: []) + seen_state = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + end + + server.handle(modern_request("tools/call", { name: "mrtr_tool", requestState: "raw-state" })) + + assert_equal "raw-state", seen_state + end + test "#handle prompts/get serializes an input_required result on a modern request" do server = Server.new(name: "mrtr_test") server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| From 3511375204fd2df86fb9804f0109ab89a714784a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 20:38:29 +0900 Subject: [PATCH 190/247] Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243 ## Motivation and Context SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate `inputSchema` properties with `x-mcp-header` so intermediaries can route on argument values without parsing bodies: the client MUST mirror each annotated argument of a `tools/call` into an `Mcp-Param-{Name}` header, encode values that cannot ride as plain ASCII field values with the `=?base64?...?=` sentinel, and omit the header for `null` or absent arguments. `MCP::Client::HTTP` sent only the standard `Mcp-Method`/`Mcp-Name` mirror headers, so every `http-custom-headers` check of the 2026-07-28 conformance requirements fails. The new `MCP::Client::McpParamHeaders` module ports the TypeScript SDK's `mcpParamHeaders` codec: - `scan` walks a tool's `inputSchema` for declarations and validates every constraint the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties (`number` is accepted alongside the spec's `string`/`integer`/`boolean` because the published referee annotates `type: "number"` parameters, the same accommodation the TypeScript SDK makes), and static reachability through a chain of `properties` keys only: an annotation under `items`, the combinators, or `$defs` invalidates the whole tool definition. - `build` resolves each declaration's path in the call's `arguments`, omits `null`/absent and non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input). `MCP::Client::HTTP` learns declarations from the `tools/list` responses passing through it (the same source of truth the TypeScript SDK's response cache uses) and mirrors on each `tools/call` alongside the existing `Mcp-Method`/`Mcp-Name` construction. Learning and mirroring exist on the modern lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per the spec's guidance to send without custom headers when no reliable declarations are available; a complete (uncursored, `nextCursor`-less) listing rebuilds the registry so declarations of unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped (`MAX_MCP_PARAM_TOOLS`, 1000) so a server rotating tool names cannot grow it without bound. On the modern lifecycle, `MCP::Client#list_tools` and `#tools` also enforce the spec's MUST: a tool definition whose `x-mcp-header` annotations violate the constraints is excluded from the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed definition does not block the valid tools - the same filtering the Python SDK's `_absorb_tool_listing` and the TypeScript SDK's cached-listing finalize apply. A value that cannot be represented as UTF-8 omits its header like the other non-representable values. The README documents the mirroring and the exclusion under the lifecycle negotiation section. The server-side validation half of the codec is a follow-up. ## How Has This Been Tested? New `test/mcp/client/mcp_param_headers_test.rb` covers the scan constraint matrix (root and non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive conversions (including `42.0` serializing as `42` and unsafe integers refusing), the encoding matrix (plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header construction with nested paths and omitted values (including a value that cannot be represented as UTF-8). `test/mcp/client/http_test.rb` drives the transport end to end over a modern connection: a `tools/list` teaching the declarations, the following `tools/call` carrying the mirrored headers, an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete listing pruning the declarations of unlisted tools. `test/mcp/client_test.rb` covers the MUST-level exclusion: the invalid definition dropped from `#tools` with the warning on a modern connection, and listed unchanged on a legacy one. `bundle exec rake` is green. ## Breaking Changes None. Requests to servers whose tools carry no `x-mcp-header` annotations are byte-for-byte unchanged, and the new headers only appear for tools that declare them. --- README.md | 14 ++ lib/mcp/client.rb | 19 +- lib/mcp/client/http.rb | 54 +++++ lib/mcp/client/mcp_param_headers.rb | 242 ++++++++++++++++++++++ test/mcp/client/http_test.rb | 139 +++++++++++++ test/mcp/client/mcp_param_headers_test.rb | 208 +++++++++++++++++++ test/mcp/client_test.rb | 48 +++++ 7 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 lib/mcp/client/mcp_param_headers.rb create mode 100644 test/mcp/client/mcp_param_headers_test.rb diff --git a/README.md b/README.md index f83cdaa..613cbd5 100644 --- a/README.md +++ b/README.md @@ -2483,6 +2483,20 @@ Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` afte the server now serves the modern lifecycle and the automatic negotiation adopted it. Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. +### Custom Headers from Tool Parameters (SEP-2243) + +On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries +an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers, so intermediaries can route +on the values without parsing bodies. The declarations are learned from `tools/list` responses: +list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values +(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`, +and a `null` or absent argument omits its header. + +Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names, +duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from +`tools/list` results on modern connections, with a warning naming the tool. +Legacy connections are unaffected: nothing is learned, mirrored, or excluded. + ## Transport Layer Interface If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 3a35495..4db0197 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "client/elicitation" +require_relative "client/mcp_param_headers" require_relative "client/modern_envelope" require_relative "client/oauth" require_relative "client/stdio" @@ -248,7 +249,9 @@ module MCP response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} - tools = (result["tools"] || []).map do |tool| + tools = (result["tools"] || []).filter_map do |tool| + next if exclude_invalid_x_mcp_header?(tool) + Tool.new( name: tool["name"], description: tool["description"], @@ -573,6 +576,20 @@ module MCP private + # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate + # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition + # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. + # Legacy connections, and transports without a lifecycle notion, list everything as before. + def exclude_invalid_x_mcp_header?(tool) + return false unless transport.respond_to?(:modern?) && transport.modern? + + scan = McpParamHeaders.scan(tool["inputSchema"]) + return false if scan[:valid] + + warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}") + true + end + # Resolves the effective SEP-2575 lifecycle mode for `connect`: # # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe, diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 538bfce..c3d8dc4 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -6,6 +6,7 @@ require_relative "../configuration" require_relative "../methods" require_relative "../protocol_deprecations" require_relative "../version" +require_relative "mcp_param_headers" require_relative "modern_envelope" module MCP @@ -39,6 +40,12 @@ module MCP # and the server transports' request cap. MAX_MESSAGE_BYTES = 4 * 1024 * 1024 + # Upper bound on the tools whose `x-mcp-header` declarations are retained for + # `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing + # (the spec's guidance to send without custom headers), so a server rotating tool names across + # `tools/list` responses cannot grow the registry without bound. + MAX_MCP_PARAM_TOOLS = 1000 + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host # is trivially observed and stolen. @@ -255,6 +262,7 @@ module MCP @listener_thread = nil @modern_client_info = nil @modern_capabilities = nil + @mcp_param_declarations = {} end # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. @@ -380,6 +388,7 @@ module MCP body = resolve_response_body(stream, response, method, params) capture_session_info(method, response, body) if response + capture_mcp_param_declarations(method, params, body) body rescue MessageTooLargeError => e @@ -712,11 +721,56 @@ module MCP name = params[:name] || params["name"] name = params[:uri] || params["uri"] unless name.is_a?(String) metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String) + + if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]]) + arguments = params[:arguments] || params["arguments"] + + metadata_headers.merge!(McpParamHeaders.build(declarations, arguments)) + end end metadata_headers end + # Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises, + # so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243). + # The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs, + # so legacy connections learn nothing. Only a valid, non-empty declaration set is kept: + # an invalid tool definition mirrors nothing, + # following the spec's guidance to send without custom headers when no reliable declarations are available. + def capture_mcp_param_declarations(method, params, body) + return unless modern? + return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash) + + tools = body.dig("result", "tools") + return unless tools.is_a?(Array) + + # An uncursored request answered without `nextCursor` is the complete tool universe, + # so knowledge about unlisted tools is stale; the registry is rebuilt from this listing, + # the same pruning the Python SDK applies to complete listings. + cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"]) + complete = !cursor && body.dig("result", "nextCursor").nil? + registry = complete ? {} : @mcp_param_declarations + + tools.each do |tool| + next unless tool.is_a?(Hash) + + name = tool["name"] + next unless name.is_a?(String) + + scan = McpParamHeaders.scan(tool["inputSchema"]) + if scan[:valid] && !scan[:declarations].empty? + next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS + + registry[name] = scan[:declarations] + else + registry.delete(name) + end + end + + @mcp_param_declarations = registry if complete + end + # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF, # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as # `=?base64??=`. Safe ASCII values are sent unchanged. diff --git a/lib/mcp/client/mcp_param_headers.rb b/lib/mcp/client/mcp_param_headers.rb new file mode 100644 index 0000000..4aa4d54 --- /dev/null +++ b/lib/mcp/client/mcp_param_headers.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +module MCP + class Client + # The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for + # `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers, + # with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values. + # Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`) + # lives with the transport. + # + # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters + module McpParamHeaders + # The fixed prefix every custom-parameter header carries. + HEADER_PREFIX = "Mcp-Param-" + + # The schema-extension property name a tool's `inputSchema` carries. + X_MCP_HEADER_KEY = "x-mcp-header" + + # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces, + # control characters (including CR/LF), and the HTTP delimiters. + RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze + + # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because + # the published conformance referee annotates `type: "number"` parameters and expects them + # mirrored; the TypeScript SDK makes the same accommodation. + PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze + + # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from + # the `properties`-only chain. An `x-mcp-header` under any of these invalidates + # the tool definition rather than being silently ignored. + NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + "items", + "prefixItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "patternProperties", + "dependentSchemas", + "oneOf", + "anyOf", + "allOf", + "not", + "if", + "then", + "else", + "$defs", + "definitions", + ].freeze + + # Keywords whose value maps names to subschemas rather than being one subschema or a list of them. + OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze + + # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored; + # the TypeScript SDK refuses unsafe integers the same way. + MAX_SAFE_INTEGER = (2**53) - 1 + + BASE64_SENTINEL_PREFIX = "=?base64?" + BASE64_SENTINEL_SUFFIX = "?=" + + class << self + # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint + # the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed + # declaring properties, and static reachability through a chain of `properties` keys only. + # Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`, + # or `{ valid: false, reason: "..." }` on the first violation. + def scan(input_schema) + declarations = [] + fault = visit(input_schema, [], true, declarations, {}) + + fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations } + end + + # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and + # the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows); + # a non-primitive or non-representable value is omitted rather than emitted malformed. + def build(declarations, arguments) + declarations.each_with_object({}) do |declaration, headers| + value = value_at_path(arguments, declaration[:path]) + next if value.nil? + + string_value = primitive_to_string(value) + next unless string_value + + encoded = begin + encode_value(string_value) + rescue EncodingError + # A string that cannot be represented as UTF-8 (e.g. binary data) has no header + # representation; omit it like the other non-representable values. + next + end + + headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded + end + end + + # Converts a primitive argument to its header string per the spec's type-conversion rules: + # strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become + # their decimal string. `nil` means "not representable: do not emit a header". + def primitive_to_string(value) + case value + when String + value + when true + "true" + when false + "false" + when Integer + value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil + when Float + return unless value.finite? + + # JSON has one number type: an integral float serializes without the fractional part, + # matching the `String(42.0)` the JavaScript reference emits. + value == value.truncate ? value.truncate.to_s : value.to_s + end + end + + # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value + # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`. + def encode_value(value) + return value unless needs_base64?(value) + + "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}" + end + + private + + def visit(node, path, reachable, declarations, seen_lower) + return unless node.is_a?(Hash) + + if key?(node, X_MCP_HEADER_KEY) + fault = validate_declaration(node, path, reachable, declarations, seen_lower) + + return fault if fault + end + + properties = read(node, "properties") + if properties.is_a?(Hash) + properties.each do |key, child| + fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower) + + return fault if fault + end + end + + # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass + # through with `reachable: false`, so an annotation under any of them is reported. + # `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope. + NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword| + next unless (sub = read(node, keyword)) + + branches = if sub.is_a?(Array) + sub + elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword) + sub.values + else + [sub] + end + + branches.each do |branch| + fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower) + + return fault if fault + end + end + + nil + end + + def validate_declaration(node, path, reachable, declarations, seen_lower) + if !reachable || path.empty? + return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys" + + end + + annotation = read(node, X_MCP_HEADER_KEY) + + unless annotation.is_a?(String) && !annotation.empty? + return "#{path_name(path)}: x-mcp-header MUST be a non-empty string" + end + + unless RFC9110_TOKEN.match?(annotation) + return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token" + end + + type = read(node, "type") + unless type.is_a?(String) && PERMITTED_TYPES.include?(type) + return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \ + "(got `#{type.inspect}`)" + end + + lower = annotation.downcase + prior = seen_lower[lower] + if prior + return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)" + end + + seen_lower[lower] = annotation + declarations << { path: path, header_name: annotation, type: type } + nil + end + + # A value cannot ride as a plain ASCII field value when it is empty, already shaped like + # the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing + # would strip, or contains a byte outside visible ASCII plus interior tab. + def needs_base64?(value) + return true if value.empty? + return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX) + return true if value != value.strip + + value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) } + end + + def value_at_path(root, path) + path.reduce(root) do |node, key| + break unless node.is_a?(Hash) + + read(node, key) + end + end + + # Schemas and arguments arrive with string keys off the wire but may carry symbol keys + # when constructed in Ruby; read both forms like the SDK's other readers. + def read(hash, key) + value = hash[key] + + value.nil? ? hash[key.to_sym] : value + end + + def key?(hash, key) + hash.key?(key) || hash.key?(key.to_sym) + end + + def path_name(path) + path.empty? ? "" : path.join(".") + end + end + end + end +end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index da1a016..ac1128c 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -821,6 +821,107 @@ module MCP assert_equal({ "result" => { "tools" => [] } }, response) end + def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers + # SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header` + # declarations, and the following `tools/call` mirrors the annotated arguments into + # `Mcp-Param-*` headers. + call_headers = nil + client = mcp_param_test_client( + tools: [mcp_param_annotated_tool], + on_call: ->(headers) { call_headers = headers }, + ) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "test_custom_headers", + arguments: { region: "us-west1", priority: 42, non_ascii_val: "Hello, 世界", null_val: nil }, + }, + }) + + assert_equal("us-west1", call_headers["Mcp-Param-Region"]) + assert_equal("42", call_headers["Mcp-Param-Priority"]) + assert_equal("=?base64?#{["Hello, 世界"].pack("m0")}?=", call_headers["Mcp-Param-NonAsciiVal"]) + refute(call_headers.key?("Mcp-Param-NullVal"), "a null argument must omit its header") + end + + def test_send_request_mirrors_nothing_for_a_tool_with_an_invalid_x_mcp_header_declaration + # An invalid tool definition (here a case-insensitive duplicate) mirrors nothing rather than + # emitting a partial or malformed header set. + invalid_tool = { + name: "invalid_duplicate", + inputSchema: { + type: "object", + properties: { + one: { type: "string", "x-mcp-header": "Region" }, + two: { type: "string", "x-mcp-header": "REGION" }, + }, + }, + } + call_headers = nil + client = mcp_param_test_client(tools: [invalid_tool], on_call: ->(headers) { call_headers = headers }) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "invalid_duplicate", arguments: { one: "a", two: "b" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + assert_equal("tools/call", call_headers["Mcp-Method"]) + end + + def test_send_request_mirrors_nothing_on_a_legacy_connection + # The custom headers exist on the modern lifecycle only (SEP-2243), matching + # the TypeScript and Python SDKs: without modern adoption nothing is learned or mirrored. + call_headers = nil + client = mcp_param_test_client( + tools: [mcp_param_annotated_tool], + on_call: ->(headers) { call_headers = headers }, + ) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + assert_equal("tools/call", call_headers["Mcp-Method"]) + end + + def test_send_request_prunes_declarations_dropped_by_a_complete_listing + # A complete (uncursored, `nextCursor`-less) listing is the full tool universe, + # so declarations of unlisted tools are stale and stop mirroring. + call_headers = nil + listings = [[mcp_param_annotated_tool], []] + client = mcp_param_test_client( + tools: -> { listings.shift || [] }, + on_call: ->(headers) { call_headers = headers }, + ) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { jsonrpc: "2.0", id: 2, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + end + def test_send_request_parses_sse_response_when_adapter_does_not_stream sse_body = "event: message\n" \ 'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \ @@ -2088,6 +2189,44 @@ module MCP WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive? end + # Builds an `HTTP` client over the Faraday test adapter for the SEP-2243 mirroring tests: + # the stub serves `server/discover` (so `connect(mode: :modern)` works), answers `tools/list` + # with `tools` (an Array, or a Proc returning the listing per request), and captures + # the request headers of any other POST via `on_call`. + def mcp_param_test_client(tools:, on_call:) + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do |env| + case JSON.parse(env.request_body)["method"] + when "server/discover" + discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" } + [200, { "Content-Type" => "application/json" }, { result: discover }.to_json] + when "tools/list" + listing = tools.respond_to?(:call) ? tools.call : tools + [200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json] + else + on_call.call(env.request_headers) + [200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json] + end + end + end + HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + end + + def mcp_param_annotated_tool + { + name: "test_custom_headers", + inputSchema: { + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + priority: { type: "integer", "x-mcp-header": "Priority" }, + non_ascii_val: { type: "string", "x-mcp-header": "NonAsciiVal" }, + null_val: { type: "string", "x-mcp-header": "NullVal" }, + }, + }, + } + end + def url "http://example.com" end diff --git a/test/mcp/client/mcp_param_headers_test.rb b/test/mcp/client/mcp_param_headers_test.rb new file mode 100644 index 0000000..639a0ab --- /dev/null +++ b/test/mcp/client/mcp_param_headers_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/mcp_param_headers" + +module MCP + class Client + class McpParamHeadersTest < Minitest::Test + def test_scan_collects_declarations_from_properties + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { + "region" => { "type" => "string", "x-mcp-header" => "Region" }, + "priority" => { "type" => "integer", "x-mcp-header" => "Priority" }, + "verbose" => { "type" => "boolean", "x-mcp-header" => "Verbose" }, + "plain" => { "type" => "string" }, + }, + }) + + assert(scan[:valid]) + assert_equal( + [ + { path: ["region"], header_name: "Region", type: "string" }, + { path: ["priority"], header_name: "Priority", type: "integer" }, + { path: ["verbose"], header_name: "Verbose", type: "boolean" }, + ], + scan[:declarations], + ) + end + + def test_scan_collects_nested_declarations_and_symbol_keys + scan = McpParamHeaders.scan({ + type: "object", + properties: { + options: { + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + }, + }, + }, + }) + + assert(scan[:valid]) + assert_equal([{ path: ["options", "region"], header_name: "Region", type: "string" }], scan[:declarations]) + end + + def test_scan_accepts_number_typed_declarations + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "float_val" => { "type" => "number", "x-mcp-header" => "FloatVal" } }, + }) + + assert(scan[:valid]) + assert_equal("number", scan[:declarations].first[:type]) + end + + def test_scan_rejects_an_annotation_at_the_schema_root + scan = McpParamHeaders.scan({ "type" => "object", "x-mcp-header" => "Root" }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "statically reachable") + end + + def test_scan_rejects_annotations_under_non_reachable_keywords + ["oneOf", "items", "$defs"].each do |keyword| + nested = { "type" => "string", "x-mcp-header" => "Hidden" } + sub = keyword == "$defs" ? { "entry" => nested } : [nested] + scan = McpParamHeaders.scan({ "type" => "object", keyword => sub }) + + refute(scan[:valid], "expected the annotation under #{keyword} to invalidate the schema") + end + end + + def test_scan_rejects_empty_and_non_string_annotations + ["", 42].each do |annotation| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "non-empty string") + end + end + + def test_scan_rejects_non_token_annotation_names + ["has space", "colon:name", "日本語", "line\nbreak"].each do |annotation| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, + }) + + refute(scan[:valid], "expected #{annotation.inspect} to be rejected") + assert_includes(scan[:reason], "RFC 9110") + end + end + + def test_scan_rejects_non_primitive_declaring_properties + [{ "type" => "object" }, { "type" => "array" }, {}].each do |extra| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => extra.merge("x-mcp-header" => "Value") }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "primitive-typed") + end + end + + def test_scan_rejects_case_insensitively_duplicated_names + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { + "one" => { "type" => "string", "x-mcp-header" => "Region" }, + "two" => { "type" => "string", "x-mcp-header" => "REGION" }, + }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "case-insensitively unique") + end + + def test_primitive_to_string_conversions + assert_equal("us-west1", McpParamHeaders.primitive_to_string("us-west1")) + assert_equal("true", McpParamHeaders.primitive_to_string(true)) + assert_equal("false", McpParamHeaders.primitive_to_string(false)) + assert_equal("42", McpParamHeaders.primitive_to_string(42)) + assert_equal("42", McpParamHeaders.primitive_to_string(42.0)) + assert_equal("3.14159", McpParamHeaders.primitive_to_string(3.14159)) + assert_nil(McpParamHeaders.primitive_to_string(2**53 + 1)) + assert_nil(McpParamHeaders.primitive_to_string(Float::INFINITY)) + assert_nil(McpParamHeaders.primitive_to_string({ "nested" => true })) + end + + def test_encode_value_passes_safe_ascii_through + assert_equal("us-west1", McpParamHeaders.encode_value("us-west1")) + assert_equal("SELECT * FROM users", McpParamHeaders.encode_value("SELECT * FROM users")) + end + + def test_encode_value_wraps_unsafe_values_in_the_base64_sentinel + { + "" => "", + "Hello, 世界" => "Hello, 世界", + " padded " => " padded ", + "\tindented" => "\tindented", + "line1\nline2" => "line1\nline2", + "line1\r\nline2" => "line1\r\nline2", + "=?base64?Zm9v?=" => "=?base64?Zm9v?=", + }.each do |raw, decoded| + encoded = McpParamHeaders.encode_value(raw) + + assert_match(/\A=\?base64\?.*\?=\z/, encoded, "expected #{raw.inspect} to be wrapped") + payload = encoded.delete_prefix("=?base64?").delete_suffix("?=") + assert_equal(decoded, payload.unpack1("m0").force_encoding(Encoding::UTF_8)) + end + end + + def test_build_mirrors_declared_arguments_into_prefixed_headers + declarations = [ + { path: ["region"], header_name: "Region", type: "string" }, + { path: ["priority"], header_name: "Priority", type: "integer" }, + { path: ["method_val"], header_name: "Method", type: "string" }, + ] + + headers = McpParamHeaders.build(declarations, { "region" => "us-west1", priority: 42, "method_val" => "test-method" }) + + assert_equal( + { + "Mcp-Param-Region" => "us-west1", + "Mcp-Param-Priority" => "42", + "Mcp-Param-Method" => "test-method", + }, + headers, + ) + end + + def test_build_omits_null_absent_and_non_primitive_values + declarations = [ + { path: ["null_val"], header_name: "NullVal", type: "string" }, + { path: ["absent"], header_name: "Absent", type: "string" }, + { path: ["object_val"], header_name: "ObjectVal", type: "string" }, + ] + + headers = McpParamHeaders.build(declarations, { "null_val" => nil, "object_val" => { "a" => 1 } }) + + assert_empty(headers) + end + + def test_build_reads_nested_paths + declarations = [{ path: ["options", "region"], header_name: "Region", type: "string" }] + + headers = McpParamHeaders.build(declarations, { "options" => { "region" => "us-east1" } }) + + assert_equal({ "Mcp-Param-Region" => "us-east1" }, headers) + end + + def test_build_omits_values_that_cannot_be_represented_as_utf_8 + declarations = [{ path: ["blob"], header_name: "Blob", type: "string" }] + binary = (+"\xff\xfe").force_encoding(Encoding::ASCII_8BIT) + + headers = McpParamHeaders.build(declarations, { "blob" => binary }) + + assert_empty(headers) + end + end + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index bc7cd14..b97f9f6 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -375,6 +375,54 @@ module MCP assert_equal([], tools) end + def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection + # SEP-2243: on the modern lifecycle a tool definition violating the `x-mcp-header` + # constraints MUST be excluded from `tools/list` results, with a warning naming the tool. + transport = mock + transport.stubs(:modern?).returns(true) + mock_response = { "result" => { "tools" => [ + { "name" => "valid_tool", "inputSchema" => { "type" => "object" } }, + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ] } } + transport.expects(:send_request).returns(mock_response).once + client = Client.new(transport: transport) + + # `rake` runs the suite with `-W0`, which silences `Kernel#warn`; restore warnings + # locally so the SHOULD-level warning is observable. + original_verbose = $VERBOSE + $VERBOSE = false + tools = nil + _out, warning = capture_io { tools = client.tools } + $VERBOSE = original_verbose + + assert_equal(["valid_tool"], tools.map(&:name)) + assert_includes(warning, "invalid_tool") + end + + def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection + transport = mock + transport.stubs(:modern?).returns(false) + mock_response = { "result" => { "tools" => [ + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ] } } + transport.expects(:send_request).returns(mock_response).once + client = Client.new(transport: transport) + + assert_equal(["invalid_tool"], client.tools.map(&:name)) + end + def test_call_tool_sends_request_to_transport_and_returns_content transport = mock tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) From 5864b8431c5a14921b47460964d73362945ff077 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 00:40:39 +0900 Subject: [PATCH 191/247] Stamp the required cache hints on modern cacheable results per SEP-2549 ## Motivation and Context The 2026-07-28 spec revision makes the SEP-2549 `ttlMs` / `cacheScope` cache hints REQUIRED members of the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`). The SDK's existing emission is opt-in (`Server.new(ttl_ms:, cache_scope:)`), so servers that never opted in violate the 2026-07-28 wire schema, and the conformance suite fails those scenarios on wire-schema validation (`ListToolsResult: must have required property 'cacheScope' / 'ttlMs'`) plus all eight checks of the `caching` scenario. The dispatch path now fills the hints centrally for results of requests carrying the SEP-2575 `_meta` envelope, next to the SEP-2322 `resultType` stamp: a cacheable result that does not already carry both fields gets the configured `ttl_ms` / `cache_scope` values, with `ttlMs: 0` (do not cache) and `cacheScope: "private"` filling anything unset. Values already in the result win, preserving per-result overrides. Only complete results are stamped: an SEP-2322 `input_required` round trip of `resources/read` is not a cacheable final result and stays hint-free. The unset-scope fill is `"private"` because the spec names no default scope and `"private"` is the side that cannot leak a user-dependent `resources/read` result through a shared cache; it is also the unconfigured default of the TypeScript SDK (`cacheScope: 'private'`), the Python SDK (`CacheHint.scope`), and this SDK's own `server/discover`. The opt-in emission of `apply_cache_metadata` used to fill a missing scope as `"public"` when only `ttl_ms` was configured and now fills `"private"` for the same reason, so both paths agree. Stable protocol versions otherwise keep the opt-in emission unchanged. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: modern `tools/list` and `resources/read` results carry the default hints, configured values win over the defaults, non-cacheable modern results (`ping`) stay untouched, and legacy cacheable results keep the opt-in emission. The opt-in emission tests assert the `"private"` fill for a configured `ttl_ms` without a scope. The previously failing conformance scenarios (`tools-list`, `resources-read-text`, and `caching`) pass at `--spec-version 2026-07-28` against the conformance fixture server, and the 2025-11-25 leg is unchanged. ## Breaking Changes The modern-path emission is additive: it applies only to results of requests carrying the SEP-2575 modern `_meta` envelope. One deliberate change reaches stable versions: a server configured with `ttl_ms:` but no `cache_scope:` now emits `cacheScope: "private"` instead of `"public"` on its opt-in results; declaring `cache_scope: "public"` explicitly restores the shared-cacheable scope. --- README.md | 5 +++- lib/mcp/server.rb | 31 +++++++++++++++++++--- test/mcp/server_test.rb | 59 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f83cdaa..17e3d14 100644 --- a/README.md +++ b/README.md @@ -2346,8 +2346,11 @@ Per SEP-2549, list and read results can carry cache hints telling clients how lo `0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, -`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "public"`). +`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "private"`, +the scope that keeps a potentially user-dependent result out of shared caches). When neither is set, responses are serialized exactly as before. +The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern `_meta` envelope +the server always emits them, filling unset values with the same defaults; stable protocol versions keep the opt-in behavior. ```ruby server = MCP::Server.new( diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 262352f..08dce62 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -144,6 +144,18 @@ module MCP # Allowed values for the SEP-2549 `cacheScope` cache hint. CACHE_SCOPES = ["public", "private"].freeze + # Methods whose results are cacheable per SEP-2549. + # On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results, + # so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in + # via `apply_cache_metadata`. + CACHEABLE_RESULT_METHODS = [ + Methods::TOOLS_LIST, + Methods::PROMPTS_LIST, + Methods::RESOURCES_LIST, + Methods::RESOURCES_TEMPLATES_LIST, + Methods::RESOURCES_READ, + ].freeze + attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope @@ -639,6 +651,18 @@ module MCP result = result.merge(resultType: ResultType::COMPLETE) end + # SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28, + # so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`: + # the spec names no default scope, and `"private"` is the side that cannot leak + # a user-dependent result through a shared cache, matching the TypeScript SDK's default + # and `server/discover`. Values already in the result win. + # Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped + # (the stamp above guarantees `resultType` is present on every modern Hash result by this point). + if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) && + result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope)) + result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) + end + result rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) @@ -1082,15 +1106,16 @@ module MCP end # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added - # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in - # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`). + # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, + # in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`, + # the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default). # Values already in the result win, enabling per-result overrides. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549 def apply_cache_metadata(result) explicit = result.key?(:ttlMs) || result.key?(:cacheScope) return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit - { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result) + { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) end def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 69d9b8c..634b8c6 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3856,15 +3856,16 @@ module MCP end test "list results carry ttlMs and cacheScope when ttl_ms is configured" do - # SEP-2549 cache hints. The cacheScope default is "public", matching - # the spec default and the Python SDK. + # SEP-2549 cache hints. The unset cacheScope fills as "private", the side that cannot + # leak a user-dependent result through a shared cache, matching the TypeScript and + # Python SDK defaults (the spec names no default scope). server = Server.new(name: "ttl_test", ttl_ms: 5000) ["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index| result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result] assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs" - assert_equal "public", result[:cacheScope], "#{method} missing cacheScope" + assert_equal "private", result[:cacheScope], "#{method} missing cacheScope" end end @@ -3925,7 +3926,7 @@ module MCP })[:result] assert_equal 60_000, result[:ttlMs] - assert_equal "public", result[:cacheScope] + assert_equal "private", result[:cacheScope] assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents] end @@ -4207,6 +4208,56 @@ module MCP assert_equal "complete", response.dig(:result, :resultType) end + # SEP-2549 makes the `ttlMs`/`cacheScope` cache hints REQUIRED on cacheable results at 2026-07-28; + # unset hints get the spec defaults, and stable protocol versions keep the opt-in emission. + test "modern cacheable results carry the default cache hints" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + response = server.handle(modern_request(Methods::TOOLS_LIST, {})) + + assert_equal 0, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "modern non-cacheable results carry no cache hints" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + response = server.handle(modern_request(Methods::PING, {})) + + refute response[:result].key?(:ttlMs) + refute response[:result].key?(:cacheScope) + end + + test "configured cache hints win over the modern defaults" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool], ttl_ms: 5000, cache_scope: "private") + + response = server.handle(modern_request(Methods::TOOLS_LIST, {})) + + assert_equal 5000, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "modern resources/read results carry the default cache hints" do + server = Server.new(name: "cache_hints_test") + server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }] + end + + response = server.handle(modern_request(Methods::RESOURCES_READ, { uri: "file:///x" })) + + assert_equal 0, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "legacy cacheable results keep the opt-in cache hint emission" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }) + + refute response[:result].key?(:ttlMs) + refute response[:result].key?(:cacheScope) + end + private # Builds a request carrying the SEP-2575 modern `_meta` envelope. From c40aa9cc5941ed6105bf94d6fd23fbd773149bbf Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 18:53:28 +0900 Subject: [PATCH 192/247] Serve the `subscriptions/listen` notification stream per SEP-2575 ## Motivation and Context Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream; `subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications. The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035); the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321. Wire behavior, per the draft schema: - The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types the client did not request. - The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery (`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping; the mere presence of a primitive's capability is not enough. - Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId` (the listen request id) in `_meta`. - A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream, stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path); an abrupt disconnect sends nothing. Implementation: - `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation) and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream. Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers. The matching snapshot is taken under the transport mutex, but stream writes happen outside it, matching the legacy delivery paths: a slow or stalled subscriber must not block the transport. Duplicate subscription ids close the new stream instead of double-registering. - Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out); a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods. Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain unbounded connections. - Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds (default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream). A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed, so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism. - Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too. - stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered, so the method answers `-32601`. - `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`). - The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls (`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either. Out of scope, noted for follow-ups: an external event bus for multi-worker deployments, and the client-side listen driver (the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047). Refs #389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover: the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses), opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, the 503 past the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot, no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no` on both the listen stream and the legacy GET stream. `test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio, and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that `server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway. --- README.md | 7 + conformance/server.rb | 30 ++ lib/mcp/methods.rb | 7 + lib/mcp/request_envelope.rb | 4 + lib/mcp/server.rb | 17 +- .../transports/streamable_http_transport.rb | 322 +++++++++++++++++- lib/mcp/transport.rb | 7 + .../server/transports/stdio_transport_test.rb | 21 ++ .../streamable_http_transport_test.rb | 281 ++++++++++++++- test/mcp/server_test.rb | 27 +- 10 files changed, 719 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3e3709c..2066a3e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ It implements the Model Context Protocol specification, handling model context r and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result +- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET listening stream: + the client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), + the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, + and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. Served on the Streamable HTTP modern path; + stdio answers `-32601`. Concurrent streams are capped by `max_listen_subscriptions:` (default 1000), and each stream receives an SSE keepalive + comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` + when an upstream proxy already keeps the stream alive - Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request, diff --git a/conformance/server.rb b/conformance/server.rb index dca807d..69e928a 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -633,6 +633,34 @@ module Conformance end end end + + class TestTriggerToolChange < MCP::Tool + tool_name "test_trigger_tool_change" + description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)" + + class << self + # The broadcast alone exercises the `subscriptions/listen` delivery, so no actual + # tool-list mutation is needed, matching the suite's TypeScript reference fixture. + def call(server_context:, **_args) + server_context.notify_tools_list_changed + + MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) + end + end + end + + class TestTriggerPromptChange < MCP::Tool + tool_name "test_trigger_prompt_change" + description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)" + + class << self + def call(server_context:, **_args) + server_context.notify_prompts_list_changed + + MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) + end + end + end end module Prompts @@ -872,6 +900,8 @@ module Conformance Tools::TestInputRequiredResultCapabilities, Tools::TestStreamingElicitation, Tools::TestLoggingTool, + Tools::TestTriggerToolChange, + Tools::TestTriggerPromptChange, ], prompts: [ Prompts::TestSimplePrompt, diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index f4c7df6..60af708 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -7,6 +7,10 @@ module MCP LOGGING_SET_LEVEL = "logging/setLevel" # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575). SERVER_DISCOVER = "server/discover" + # Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), + # replacing the legacy HTTP GET listening stream. Served at the transport layer + # (Streamable HTTP modern path); transports without streaming support answer `-32601`. + SUBSCRIPTIONS_LISTEN = "subscriptions/listen" PROMPTS_GET = "prompts/get" PROMPTS_LIST = "prompts/list" @@ -50,6 +54,9 @@ module MCP NOTIFICATIONS_PROGRESS = "notifications/progress" NOTIFICATIONS_CANCELLED = "notifications/cancelled" NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete" + # First message on a `subscriptions/listen` stream (SEP-2575): reports the subset + # of requested notification types the server agreed to honor. + NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged" class MissingRequiredCapabilityError < StandardError attr_reader :method diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb index 0a34549..3f1b19a 100644 --- a/lib/mcp/request_envelope.rb +++ b/lib/mcp/request_envelope.rb @@ -16,6 +16,10 @@ module MCP # Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle. # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format. LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" + # Notification-side reserved key (SEP-2575): correlates a notification delivered on + # a `subscriptions/listen` stream (and the stream's closing result) with the JSON-RPC id of + # the `subscriptions/listen` request that opened it. Not part of the request envelope triple. + SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" # Result-side counterpart of the request envelope: the server's identity rides in # the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index df68ef7..567a805 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -867,7 +867,7 @@ module MCP def discover(_request) { supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, - capabilities: capabilities, + capabilities: discover_capabilities, instructions: instructions, _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info }, }.compact.merge( @@ -879,6 +879,21 @@ module MCP ) end + # Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags + # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC + # (e.g. stdio), matching the Python SDK's era-aware capability derivation. + def discover_capabilities + return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen? + + capabilities.each_with_object({}) do |(name, value), stripped| + stripped[name] = if value.is_a?(Hash) + value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) } + else + value + end + end + end + def configure_logging_level(request, session: nil) if capabilities[:logging].nil? raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index f49dba6..8ccc13f 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "json" +require_relative "../../result_type" require_relative "../../transport" # This file is autoloaded only when `StreamableHTTPTransport` is referenced, @@ -18,10 +19,16 @@ module MCP class StreamableHTTPTransport < Transport class InvalidJsonError < StandardError; end + # `x-accel-buffering: no` tells reverse proxies (nginx and friends) not to buffer the response, + # which the spec asks of every SSE stream: a buffering proxy holds events back instead of + # delivering them as they are written, and on a long-lived `subscriptions/listen` stream that + # also swallows the keepalive frames a dropped peer would otherwise be detected by. + # The TypeScript and Python SDKs send it on their SSE responses for the same reason. SSE_HEADERS = { "content-type" => "text/event-stream", "cache-control" => "no-cache", "connection" => "keep-alive", + "x-accel-buffering" => "no", }.freeze # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, @@ -35,6 +42,12 @@ module MCP DEFAULT_SESSION_IDLE_TIMEOUT = 1800 DEFAULT_MAX_SESSIONS = 10_000 + # Cap on concurrent `subscriptions/listen` streams (SEP-2575). Each stream holds an open SSE connection + # for its lifetime, so without a bound an unauthenticated client can retain unbounded connections, + # like the session-flood case `DEFAULT_MAX_SESSIONS` guards. A listen request past the cap is rejected with HTTP 503; + # pass `max_listen_subscriptions: nil` to opt out. + DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000 + # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry). UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT @@ -58,6 +71,13 @@ module MCP # the same non-delivery degradation they have on every other undeliverable path. MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000 + # Interval in seconds between SSE keepalive comment frames on a `subscriptions/listen` stream. + # Without them a silently dropped connection holds its slot until the next fan-out write fails, + # so on a quiet server a dead peer would occupy a `max_listen_subscriptions` slot indefinitely. + # The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's + # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream. + DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15 + # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -87,6 +107,13 @@ module MCP # ownership is not enforced. # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger # requests are rejected with HTTP 413. Defaults to 4 MiB. + # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen` + # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables + # the cap. + # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames + # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer + # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable + # when an upstream proxy already keeps the stream alive. def initialize( server, stateless: false, @@ -97,7 +124,9 @@ module MCP allowed_hosts: nil, dns_rebinding_protection: true, session_request_validator: nil, - max_request_bytes: DEFAULT_MAX_REQUEST_BYTES + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, + max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, + listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -114,6 +143,11 @@ module MCP @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} + # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575). + # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, + # which is a follow-up. + @listen_subscriptions = {} + # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits; # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575). @modern_request_sinks = {} @@ -147,6 +181,18 @@ module MCP @max_request_bytes = max_request_bytes + if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0) + raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil" + end + + @max_listen_subscriptions = max_listen_subscriptions + + if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0) + raise ArgumentError, "listen_keepalive_interval must be a positive number or nil" + end + + @listen_keepalive_interval = listen_keepalive_interval + start_reaper_thread if @session_idle_timeout end @@ -163,6 +209,15 @@ module MCP # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575). NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze + # Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on + # a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI + # against `resourceSubscriptions` instead. + LISTEN_FILTER_FIELDS = { + Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged, + Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged, + Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged, + }.freeze + # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404 # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors, # stays 200, matching the Python SDK's status ladder. @@ -180,6 +235,12 @@ module MCP handle_request(Rack::Request.new(env)) end + # The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path, + # so `Server#discover` may advertise `listChanged`/`subscribe` capability flags. + def serves_subscriptions_listen? + true + end + def handle_request(request) rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error @@ -235,6 +296,8 @@ module MCP @reaper_thread&.kill @reaper_thread = nil + teardown_listen_subscriptions + removed_sessions = @mutex.synchronize do @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) } end @@ -246,6 +309,12 @@ module MCP end def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below: + # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too. + # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions, + # and a sink capturing the notification for its own response stream must not hide it from other subscriptions. + deliver_to_listen_subscriptions(method, params) + notification = { jsonrpc: "2.0", method: method, @@ -588,6 +657,10 @@ module MCP mismatch_error = validate_modern_headers(request, body, header_version) return mismatch_error if mismatch_error + # `subscriptions/listen` is a long-lived notification stream served at the transport layer; + # it never dispatches through `Server#handle`. + return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN + session = modern_session notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } begin @@ -677,6 +750,253 @@ module MCP nil end + # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is + # `notifications/subscriptions/acknowledged` with the subset of requested notification types + # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId` + # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` + # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every + # `listen_keepalive_interval` seconds so a dropped connection frees its slot. + def handle_subscriptions_listen(body) + request_id = body[:id] + params = body[:params] + + # A listen frame without an id could never receive stream teardown correlation. + unless request_id + return invalid_request_response("Invalid Request: subscriptions/listen requires an id") + end + + begin + if RequestEnvelope.modern?(params) + RequestEnvelope.parse!(params, request: params) + else + return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope") + end + rescue Server::RequestHandlerError => e + return json_rpc_error_response( + status: 400, + code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: e.message, + data: e.error_data, + id: request_id, + ) + end + + filter = params[:notifications] + unless filter.is_a?(Hash) + return json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + message: "Invalid params: subscriptions/listen requires a `notifications` filter object", + id: request_id, + ) + end + + # Best-effort cap check before committing to the SSE response; the registration inside + # `listen_sse_body` re-checks atomically for the race between two concurrent listens + # crossing the cap together. + if listen_subscriptions_full? + return too_many_listen_subscriptions_response(request_id) + end + + [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))] + end + + def listen_subscriptions_full? + return false unless @max_listen_subscriptions + + @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions } + end + + def too_many_listen_subscriptions_response(request_id) + json_rpc_error_response( + status: 503, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached", + id: request_id, + ) + end + + # The proc registers the stream and returns, leaving the response open like + # the legacy GET stream (`create_sse_body`). + def listen_sse_body(request_id, honored) + proc do |stream| + rejected = false + @mutex.synchronize do + if @listen_subscriptions.key?(request_id) || + (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) + rejected = true + else + @listen_subscriptions[request_id] = { stream: stream, filter: honored } + end + end + + if rejected + close_stream_safely(stream) + else + acknowledgement = { + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED, + params: { + notifications: honored, + _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, + }, + } + + begin + send_to_stream(stream, acknowledgement) + start_listen_keepalive_thread(request_id) + rescue *STREAM_WRITE_ERRORS + remove_listen_subscription(request_id) + close_stream_safely(stream) + end + end + end + end + + # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped + # connection is detected and its slot freed, rather than held until the next fan-out write. + # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame) + # cannot corrupt an interleaved notification's JSON. + def start_listen_keepalive_thread(request_id) + return unless @listen_keepalive_interval + + Thread.new do + while listen_subscription_active?(request_id) + sleep(@listen_keepalive_interval) + send_listen_keepalive_ping(request_id) + end + rescue *STREAM_WRITE_ERRORS + # The peer went away; the ensure frees the slot. A dropped listen stream is the normal + # way this loop ends, so it is not reported. + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { subscription_id: request_id }) + ensure + stream = @mutex.synchronize do + subscription = @listen_subscriptions.delete(request_id) + subscription && subscription[:stream] + end + close_stream_safely(stream) if stream + end + end + + def listen_subscription_active?(request_id) + @mutex.synchronize { @listen_subscriptions.key?(request_id) } + end + + # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block + # every other subscription on `@mutex`. A write error propagates to end the keepalive loop. + def send_listen_keepalive_ping(request_id) + stream = @mutex.synchronize do + subscription = @listen_subscriptions[request_id] + subscription && subscription[:stream] + end + return unless stream + + send_ping_to_stream(stream) + end + + # Per SEP-2575, the server MUST NOT send notification types the client has not requested, + # and the acknowledgement only includes types the server actually supports + # (derived from its declared capabilities). + def honored_filter(filter) + capabilities = @server.capabilities + honored = {} + honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged) + honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged) + honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged) + + subscriptions = filter[:resourceSubscriptions] + if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty? + honored[:resourceSubscriptions] = subscriptions + end + + honored + end + + # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive + # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation + # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough. + def capability_flag?(capabilities, name, flag) + value = capabilities[name] || capabilities[name.to_s] + return false unless value.is_a?(Hash) + + !!(value[flag] || value[flag.to_s]) + end + + # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it, + # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter + # (not the requested one) enforces the MUST NOT-send-unrequested-types rule. + def deliver_to_listen_subscriptions(method, params) + field = LISTEN_FILTER_FIELDS[method] + return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED + + # The matching snapshot is taken under `@mutex`, but stream writes happen outside it: + # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths. + matched = @mutex.synchronize do + @listen_subscriptions.filter_map do |request_id, subscription| + hit = if field + subscription[:filter][field] + else + uris = subscription[:filter][:resourceSubscriptions] + uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil + uris.is_a?(Array) && uris.include?(uri) + end + + [request_id, subscription[:stream]] if hit + end + end + + matched.each do |request_id, stream| + meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id } + notification_params = (params || {}).merge(_meta: meta) + notification = { jsonrpc: "2.0", method: method, params: notification_params } + + begin + send_to_stream(stream, notification) + rescue *STREAM_WRITE_ERRORS => e + MCP.configuration.exception_reporter.call( + e, + { subscription_id: request_id, error: "Failed to send notification" }, + ) + remove_listen_subscription(request_id) + close_stream_safely(stream) + end + end + end + + def remove_listen_subscription(request_id) + @mutex.synchronize { @listen_subscriptions.delete(request_id) } + end + + # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response + # before the stream closes. + def teardown_listen_subscriptions + removed = @mutex.synchronize do + subscriptions = @listen_subscriptions.dup + @listen_subscriptions.clear + subscriptions + end + + removed.each do |request_id, subscription| + begin + send_to_stream(subscription[:stream], { + jsonrpc: "2.0", + id: request_id, + result: { + # `SubscriptionsListenResult` is served at the transport layer and never + # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is + # stamped at its construction site. + resultType: ResultType::COMPLETE, + _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, + }, + }) + rescue *STREAM_WRITE_ERRORS + nil + end + close_stream_safely(subscription[:stream]) + end + end + def header_mismatch_response(message, id) json_rpc_error_response( status: 400, diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index 9f94c60..c48e3c9 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -50,6 +50,13 @@ module MCP raise NotImplementedError, "Subclasses must implement send_request" end + # Whether the transport serves the `subscriptions/listen` notification stream (MCP 2026-07-28, SEP-2575). + # `Server#discover` strips the `listChanged`/`subscribe` capability flags when the transport cannot deliver + # those notifications in the modern lifecycle. + def serves_subscriptions_listen? + false + end + private def generate_request_id diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 699bcb7..5b76a74 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -634,6 +634,27 @@ module MCP end end + test "subscriptions/listen is not served over stdio and answers -32601" do + # Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream. + listen = { + jsonrpc: "2.0", + method: "subscriptions/listen", + id: "listen-1", + params: { + notifications: { toolsListChanged: true }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + + responses = run_transport_session([listen]) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) + end + test "#send_request raises on a modern-locked session" do run_transport_session([modern_tools_list_request(id: 1)]) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 6edce06..aad90d8 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -41,7 +41,9 @@ module MCP prompts: [], resources: [], ) - @transport = StreamableHTTPTransport.new(@server) + # Disable the listen keepalive by default so opening a listen stream in a test does not + # spawn a background timer thread; the keepalive is exercised by its own dedicated tests. + @transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: nil) end teardown do @@ -5772,6 +5774,248 @@ module MCP transport.close end + test "subscriptions/listen opens an SSE stream and acknowledges the honored subset first" do + # `resourceSubscriptions` honoring requires the `resources.subscribe` capability flag, + # which the server defaults do not declare. + server = Server.new( + name: "listen_test", + capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + events = sse_events(io) + assert_equal(1, events.size) + ack = events[0] + assert_equal("notifications/subscriptions/acknowledged", ack["method"]) + assert_equal( + { "toolsListChanged" => true, "resourceSubscriptions" => ["file:///a.txt"] }, + ack.dig("params", "notifications"), + ) + assert_equal("listen-1", ack.dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) + ensure + transport.close + end + + test "SSE responses disable proxy buffering" do + # The spec asks servers to send `X-Accel-Buffering: no` on SSE streams so a reverse proxy + # delivers events as they are written; on a listen stream, buffering would also swallow + # the keepalive frames that detect a dropped peer. + listen = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + session_id = initialize_test_session + get_stream = @transport.handle_request(create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id })) + + assert_equal("no", listen[1]["x-accel-buffering"]) + assert_equal("no", get_stream[1]["x-accel-buffering"]) + end + + test "subscriptions/listen acknowledgement omits notification types the server does not support" do + server = Server.new(name: "listen_test", capabilities: { tools: { listChanged: true } }) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, promptsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + ack = sse_events(io)[0] + assert_equal({ "toolsListChanged" => true }, ack.dig("params", "notifications")) + ensure + transport.close + end + + test "subscriptions/listen requires a notifications filter object" do + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: {}), + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "subscriptions/listen requires the modern _meta envelope" do + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "subscriptions/listen", id: "listen-1", params: { notifications: {} } }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "subscriptions/listen delivers only opted-in notifications with the subscriptionId" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + @server.notify_tools_list_changed + # Not opted in: MUST NOT be delivered. + @server.notify_prompts_list_changed + + events = sse_events(io) + assert_equal 2, events.size + assert_equal "notifications/subscriptions/acknowledged", events[0]["method"] + assert_equal "notifications/tools/list_changed", events[1]["method"] + assert_equal "listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + end + + test "subscriptions/listen delivers resource updates only for subscribed URIs" do + server = Server.new( + name: "listen_test", + capabilities: { resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { resourceSubscriptions: ["file:///subscribed.txt"] }, + transport: transport, + ) + + # `**{}` keeps the params Hash positional on Ruby 2.7, matching the other `send_notification` tests. + transport.send_notification("notifications/resources/updated", { uri: "file:///subscribed.txt" }, **{}) + transport.send_notification("notifications/resources/updated", { uri: "file:///other.txt" }, **{}) + + events = sse_events(io) + assert_equal(2, events.size) + assert_equal("notifications/resources/updated", events[1]["method"]) + assert_equal("file:///subscribed.txt", events[1].dig("params", "uri")) + assert_equal("listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) + ensure + transport.close + end + + test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do + first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }) + + @server.notify_tools_list_changed + + first_events = sse_events(first) + second_events = sse_events(second) + assert_equal "listen-1", first_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + assert_equal "listen-2", second_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + end + + test "subscriptions/listen closes gracefully with a SubscriptionsListenResult on transport close" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + @transport.close + + events = sse_events(io) + result = events.last + assert_equal "listen-1", result["id"] + assert_equal "listen-1", result.dig("result", "_meta", "io.modelcontextprotocol/subscriptionId") + # `SubscriptionsListenResult` is a 2026-07-28 result, so it carries the REQUIRED `resultType`. + assert_equal "complete", result.dig("result", "resultType") + assert_predicate io, :closed? + + # The subscription is gone: further notifications are not delivered anywhere. + @server.notify_tools_list_changed + assert_equal events, sse_events(io) + end + + test "subscriptions/listen honoring reads the capability flags, not capability presence" do + # A server declaring `tools` without `listChanged: true` promises no + # list-changed delivery, so the acknowledgement omits the type. + server = Server.new(name: "listen_test", capabilities: { tools: {}, resources: { listChanged: true } }) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + ack = sse_events(io)[0] + assert_equal({ "resourcesListChanged" => true }, ack.dig("params", "notifications")) + ensure + transport.close + end + + test "subscriptions/listen past the concurrent stream cap is rejected with 503" do + transport = StreamableHTTPTransport.new(@server, max_listen_subscriptions: 1) + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) + + response = transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-2", params: { notifications: { toolsListChanged: true } }), + )) + + assert_equal(503, response[0]) + body = JSON.parse(response[2][0]) + assert_equal("listen-2", body["id"]) + assert_includes(body.dig("error", "message"), "maximum concurrent subscriptions/listen streams") + ensure + transport.close + end + + test "subscriptions/listen rejects a duplicate subscription id by closing the new stream" do + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + duplicate = StringIO.new + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + response[2].call(duplicate) + + assert_predicate duplicate, :closed? + end + + test "listen keepalive writes a comment frame outside the mutex" do + # Register a subscription backed by a mutex-probing stream, then invoke the ping directly. + mutex = @transport.instance_variable_get(:@mutex) + writes = { total: 0, under_mutex: 0 } + ping = nil + stream = Object.new + stream.define_singleton_method(:write) do |data| + writes[:total] += 1 + mutex.try_lock ? mutex.unlock : writes[:under_mutex] += 1 + ping = data + end + stream.define_singleton_method(:flush) {} + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { stream: stream, filter: {} } + + @transport.send(:send_listen_keepalive_ping, "listen-1") + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex" + assert_match(/\A: ping /, ping) + end + + test "listen keepalive frees the slot and closes the stream when the peer is gone" do + transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0.01) + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) + io.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 + until transport.instance_variable_get(:@listen_subscriptions).empty? + flunk("keepalive did not free the slot for a dead peer") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + + assert_predicate io, :closed? + ensure + transport.close + end + + test "listen keepalive is not started when the interval is nil" do + before = Thread.list.size + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + assert_equal before, Thread.list.size, "a nil interval must not spawn a keepalive thread" + end + + test "listen_keepalive_interval rejects a non-positive value" do + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: -1) } + end + private def initialize_test_session(id: "init") @@ -5885,6 +6129,41 @@ module MCP ), }.to_json end + + def modern_listen_body(id:, params:) + { + jsonrpc: "2.0", + method: "subscriptions/listen", + id: id, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + ), + }.to_json + end + + # Opens a `subscriptions/listen` stream on the modern path and returns the StringIO + # backing the SSE stream (already carrying the acknowledgement event). + def open_listen_stream(id:, notifications:, transport: @transport) + response = transport.handle_request(modern_rack_request( + modern_listen_body(id: id, params: { notifications: notifications }), + )) + + assert_equal(200, response[0]) + assert_equal("text/event-stream", response[1]["content-type"]) + + io = StringIO.new + response[2].call(io) + io + end + + # Parses every `data:` event written to an SSE StringIO. + def sse_events(io) + io.string.scan(/^data: (.+)$/).map { |match| JSON.parse(match[0]) } + end end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index fa99de6..446401d 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -130,7 +130,9 @@ module MCP result = response[:result] assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions] - assert_equal @server.capabilities, result[:capabilities] + # Without a `subscriptions/listen`-serving transport, the `listChanged` flags are + # stripped from the advertised capabilities (see the dedicated tests below). + assert_equal @server.capabilities.keys, result[:capabilities].keys # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp, # not a top-level `serverInfo` field. assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name) @@ -139,6 +141,29 @@ module MCP assert_equal "Optional instructions for the client", result[:instructions] end + test "#handle server/discover strips listChanged and subscribe flags without a listen-serving transport" do + server = Server.new(name: "discover_test", capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + }) + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal({ tools: {}, resources: {}, logging: {} }, result[:capabilities]) + end + + test "#handle server/discover keeps listChanged flags when the transport serves subscriptions/listen" do + server = Server.new(name: "discover_test", capabilities: { tools: { listChanged: true } }) + transport = mock + transport.stubs(:serves_subscriptions_listen?).returns(true) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal({ tools: { listChanged: true } }, result[:capabilities]) + end + test "#handle server/discover responds before initialize and regardless of capabilities" do # Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate. server = Server.new(name: "discover_test", capabilities: {}) From 248b5f8403394b529d3a5ad15601027e9b92ec4e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 5 Aug 2026 02:45:15 +0900 Subject: [PATCH 193/247] Bound the total wait across SSE reconnection attempts ## Motivation and Context The SSE `retry:` field is chosen by the server, and `MCP::Client::HTTP` passed it to `sleep` unchanged in both reconnection paths. `await_response_after_disconnect` runs on the calling thread, so a server that primes a stream with an event id, closes it gracefully, and asks for a large `retry:` interval keeps that thread waiting for as long as it likes. `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, not how long it waits before each attempt, and no I/O timeout covers a sleep. The obvious fix, clamping `retry:` to a ceiling, is not available: the spec says a client "MUST respect the `retry` field, waiting the given number of milliseconds before attempting to reconnect", and reconnecting sooner than a server asked for is also the wrong direction to err in, since `retry:` is how an overloaded server asks for room. Both reference SDKs pass the value through untouched. What the spec does allow is waiting longer. The `retry` field it points at is the one WHATWG HTML defines, whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally, wait some more." It also leaves reconnecting at all a SHOULD, which this client already declines once `MAX_RECONNECTION_ATTEMPTS` is reached. Both fixes below sit inside that room; neither shortens a wait. `await_response_after_disconnect` now carries a deadline, `max_reconnection_wait:` seconds (`MAX_RECONNECTION_WAIT`, 300, the same budget as `SSE_LISTENER_READ_TIMEOUT`). Before each attempt it compares the delay the server asked for against the remaining budget, and stops reconnecting when honoring it in full would run past the deadline. The server's interval is therefore either waited out exactly or not acted on at all, and the calling thread is released as soon as the answer is known. On the reported case, a day-long `retry:`, the thread now returns without sleeping at all. Whatever is left of the budget also becomes the resumed stream's read timeout, so the bound covers a server that accepts the GET and then sends nothing. That GET previously relied on whatever the Faraday adapter defaults to, which is 60 seconds for Net::HTTP but need not exist at all for a caller-supplied adapter. `listen_for_server_requests` already guarded its own GET this way with `SSE_LISTENER_READ_TIMEOUT`. The other path has the opposite problem. `listen_for_server_requests` treats a graceful close as success and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and the listening stream reconnects in a tight loop. A `MIN_RECONNECTION_DELAY_MS` floor of 100ms stops that, and is the "optionally, wait some more" case exactly. That path gets no deadline: it runs on a thread this client owns and is meant to poll indefinitely (the spec asks clients to "poll" a closed stream by reconnecting), so a long `retry:` there idles the SDK's own listener rather than the embedding application. `DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` are unchanged and still match the Python SDK. ## How Has This Been Tested? New tests in `test/mcp/client/http_test.rb` cover a day-long `retry:` releasing the calling thread without sleeping, a delay that fits the budget still being waited out in full and resuming normally, a caller-supplied `max_reconnection_wait:` stopping the resume with a message naming the budget, and the argument validation. Both reconnection paths are covered for the unusable values a server can send: `retry: 0` is raised to the floor on the resumption path and on the listening stream, and negative or non-numeric values (`-5000`, `abc`, `1e6`, `500ms`) fall back to the default delay, since the SSE parser accepts only a run of digits. The existing reconnection tests, whose `retry:` values fit the default budget, are unaffected. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance `sse-retry` scenario scores `client-sse-retry-timing`, which checks the reconnect happens neither early nor very late against a 500ms `retry:`; since no wait is ever shortened, that check is unaffected by the size of the value it uses. ## Breaking Changes A request whose resumption cannot complete within `max_reconnection_wait:` (300 seconds by default) now fails instead of waiting however long the server asked for. Servers asking for intervals that fit the budget are unaffected. Pass a larger `max_reconnection_wait:` if you expect longer. --- README.md | 7 ++ lib/mcp/client/http.rb | 88 +++++++++++++++++++-- test/mcp/client/http_test.rb | 148 +++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d7a527..998d43c 100644 --- a/README.md +++ b/README.md @@ -2647,6 +2647,13 @@ The server will send `notifications/progress` back to the client during executio an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses. +`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts, +and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread, +so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes). +The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead, +the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin +the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at. + #### Server-to-Client Requests (Elicitation) Servers can send requests back to the client while one of the client's own requests is in flight - for example, diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index c3d8dc4..1a7b4d4 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -27,6 +27,29 @@ module MCP DEFAULT_RECONNECTION_DELAY_MS = 1000 MAX_RECONNECTION_ATTEMPTS = 2 + # Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success + # and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop. + # Waiting longer than the server asked for is explicitly allowed: the `retry` field the spec points at is + # the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time + # of the event source. Optionally, wait some more." Waiting *less* is what the spec's MUST rules out, + # and nothing here ever does that. + # + # https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time + MIN_RECONNECTION_DELAY_MS = 100 + + # Budget in seconds for `await_response_after_disconnect`: it gates every wait between reconnection attempts, + # and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread, + # so without a deadline a server answering with a large `retry:` parks a thread of the embedding application for + # as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, + # not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops + # reconnecting instead, the same kind of decision the attempt cap already makes, so the server's `retry:` is + # always honored in full or not acted on at all. + # + # Matches `SSE_LISTENER_READ_TIMEOUT`, this client's other "how long to wait on a quiet SSE stream" value. + # `listen_for_server_requests` has no such deadline: it runs on a thread this client owns and is meant + # to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application. + MAX_RECONNECTION_WAIT = 300 + # How long the standalone GET listening stream may stay idle before the read times out # and the connection is counted as a failure and retried. Matches the Python SDK's # `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout @@ -216,13 +239,24 @@ module MCP attr_reader :url, :session_id, :protocol_version, :server_info, :oauth - def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block) + def initialize( + url:, + headers: {}, + oauth: nil, + max_message_bytes: MAX_MESSAGE_BYTES, + max_reconnection_wait: MAX_RECONNECTION_WAIT, + &block + ) # `nil` or a non-positive value would make the buffering unbounded and silently # disable the protection, so reject it up front. unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0 raise ArgumentError, "max_message_bytes must be a positive Integer" end + unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0 + raise ArgumentError, "max_reconnection_wait must be a positive number" + end + if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) # Mask credentials (userinfo) and query parameters before quoting the URL in the error message # so they cannot leak into logs. @@ -237,6 +271,7 @@ module MCP @faraday_customizer = block @oauth = oauth @max_message_bytes = max_message_bytes + @max_reconnection_wait = max_reconnection_wait # Snapshot the canonical URL at construction time. This single value # serves two related roles, both of which need to see the query string: # @@ -965,10 +1000,26 @@ module MCP stream.reset_parser! - sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) + sleep(reconnection_delay_seconds(stream)) end end + # The reconnection delay in seconds: the server's `retry:` value when it sent one and + # the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server + # asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all; + # the SSE parser drops it, so those arrive here as the default rather than as something to guard. + def reconnection_delay_seconds(stream) + delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS + + [delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0 + end + + # Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant + # still asks the adapter for a timeout rather than for "no timeout". + def remaining_reconnection_budget(deadline) + [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max + end + def require_faraday! require "faraday" rescue LoadError @@ -1085,23 +1136,43 @@ module MCP # SEP-1699 resumability: the server closed the SSE stream after a priming event # without delivering the response. Treat the graceful close like a network failure: - # wait the server-specified `retry:` interval (default 1000ms), then reconnect with + # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with # a GET carrying `Last-Event-ID` so the server can replay the pending response on # the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport` # reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap). + # + # This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as + # by their count: the deadline gates each wait, and what is left of it becomes the read timeout of + # the resumed stream. A delay that would run past the deadline is never shortened: the client stops + # reconnecting instead, so the server's `retry:` is honored in full or not acted on at all. # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 def await_response_after_disconnect(stream, method, params) stream.abortable = true + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait + gave_up_waiting = false MAX_RECONNECTION_ATTEMPTS.times do - sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) + delay = reconnection_delay_seconds(stream) + if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline + gave_up_waiting = true + break + end + + sleep(delay) stream.reset_parser! + # Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to + # whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way; + # without this, a caller-supplied adapter with no default read timeout would let a server hold + # the connection open past the budget by simply sending nothing. + read_timeout = remaining_reconnection_budget(deadline) + reconnect_response = begin client.get("") do |req| req.headers.update(session_headers) req.headers["Accept"] = SSE_ACCEPT_HEADER req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id + req.options.read_timeout = read_timeout req.options.on_data = stream.on_data end rescue StreamAbort @@ -1117,9 +1188,14 @@ module MCP return stream.response if stream.response end + reason = if gave_up_waiting + "the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget" + else + "#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts" + end + raise RequestHandlerError.new( - "Server closed the SSE stream without a response for #{method} " \ - "after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts", + "Server closed the SSE stream without a response for #{method} after #{reason}", { method: method, params: params }, error_type: :internal_error, ) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index ac1128c..7d0d859 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -765,6 +765,125 @@ module MCP assert_equal({ "content" => [] }, response["result"]) end + def test_send_request_releases_the_calling_thread_on_an_excessive_reconnection_delay + # A server priming a stream, closing it, and asking for a day-long `retry:` used to park + # the calling thread for that long. The default budget now stops the resume without sleeping. + stub_reconnection_with_retry(86_400_000) + client.expects(:sleep).never + + error = assert_raises(MCP::Client::RequestHandlerError) do + client.send_request(request: reconnection_request) + end + + assert_includes error.message, "reconnection budget" + end + + def test_send_request_raises_a_server_reconnection_delay_of_zero_to_the_minimum + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 0\ndata:\n\n", + ) + + get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_honors_a_reconnection_delay_that_fits_the_budget + # Nothing is shortened while the server's `retry:` fits: the client waits it out and resumes. + custom_client = HTTP.new(url: url, max_reconnection_wait: 60) + + stub_reconnection_with_retry(30_000) + custom_client.expects(:sleep).with(30.0) + + response = custom_client.send_request(request: reconnection_request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_gives_up_rather_than_reconnect_before_the_server_asked + # A delay past the budget is never shortened; the client stops trying to resume instead, so + # the calling thread is released immediately rather than after the server's chosen interval. + custom_client = HTTP.new(url: url, max_reconnection_wait: 10) + + stub_reconnection_with_retry(86_400_000) + custom_client.expects(:sleep).never + + error = assert_raises(MCP::Client::RequestHandlerError) do + custom_client.send_request(request: reconnection_request) + end + + assert_includes error.message, "would exceed the 10 second reconnection budget" + end + + def test_send_request_falls_back_to_the_default_delay_for_an_unusable_retry_value + # The SSE parser only accepts a run of digits, so a negative or non-numeric `retry:` never reaches + # the delay calculation as a value; both arrive as "the server sent none". + ["-5000", "abc", "1e6", "500ms"].each do |value| + WebMock.reset! + fresh_client = HTTP.new(url: url) + + stub_reconnection_with_retry(value) + fresh_client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0) + + response = fresh_client.send_request(request: reconnection_request) + + assert_equal({ "content" => [] }, response["result"]) + end + end + + def test_listener_applies_the_delay_floor_to_a_zero_retry_value + # The listening stream reconnects indefinitely after a graceful close, so a `retry: 0` would spin + # without the floor. The 500s that follow let the listener reach its failure cap and stop. + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return( + { status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "id: e1\nretry: 0\ndata:\n\n" }, + { status: 500 }, + { status: 500 }, + ) + + client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0).at_least_once + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + ensure + client.close + end + + def test_raises_argument_error_when_max_reconnection_wait_is_not_positive + [0, -1, "60", nil].each do |value| + error = assert_raises(ArgumentError) { HTTP.new(url: url, max_reconnection_wait: value) } + + assert_equal("max_reconnection_wait must be a positive number", error.message) + end + end + def test_send_request_raises_after_reconnection_attempts_are_exhausted request = { jsonrpc: "2.0", @@ -2234,6 +2353,35 @@ module MCP def client @client ||= HTTP.new(url: url) end + + # The SEP-1699 reconnection exchange: a `tools/call` whose SSE stream carries a priming event + # and the given `retry:` before closing, and a GET that replays the result for `Last-Event-ID`. + def reconnection_request + { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + end + + def stub_reconnection_with_retry(retry_ms) + stub_request(:post, url).with( + body: reconnection_request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: #{retry_ms}\ndata:\n\n", + ) + + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: %(data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}\n\n), + ) + end end end end From 927ccdd161412c61e24eefb58ca4983ad27cce24 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 12 Aug 2026 02:26:41 +0900 Subject: [PATCH 194/247] [Conformance] Cover the JSON Schema 2020-12 preservation scenario ## Motivation and Context The `json-schema-2020-12-preservation` client scenario (SEP-1613, SEP-2106; not scored for 2026-07-28, as it was added after the requirements anchor) failed only because the conformance client had no branch for it: an unknown scenario aborts after connecting, so the harness never observed a `tools/list` nor the schema echo it diffs. The client itself already preserves the vocabulary the scenario checks (`$schema`, `$defs` with `$anchor`, `$ref`, `allOf`/`anyOf`, `if`/`then`/`else`, `additionalProperties`): `Client::Tool#input_schema` is the parsed `tools/list` response untouched, with no dereferencing, validation-time rewriting, or keyword stripping, the same fixture-echo approach the TypeScript and Python conformance clients take. The new branch lists the tools and echoes the focal tool's observed `inputSchema` verbatim through `json_schema_echo`, and the expected-failures entry is removed. ## How Has This Been Tested? The scenario passes 9/9 at its default 2025-11-25 wire. On top of the modern-lifecycle client selection (the SEP-2322 driver branch) it also passes 9/9 at `--spec-version 2026-07-28`, where the SEP-2106 keyword checks are failure-gated instead of skipped. `bundle exec rake` is green, with the conformance task passing its baseline check on both legs, and RuboCop reports no offenses. ## Breaking Changes None. The change is confined to the conformance client and its baseline; the shipped gem is untouched. --- conformance/client.rb | 8 ++++++++ conformance/expected_failures.yml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/conformance/client.rb b/conformance/client.rb index c103414..23403d3 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -182,6 +182,14 @@ begin case scenario when "initialize" client.tools + when "json-schema-2020-12-preservation" + # SEP-1613/SEP-2106: echo the focal tool's observed inputSchema verbatim so the harness + # can diff what the client preserved. Preservation holds because `Client::Tool#input_schema` + # is the parsed response untouched. + tools = client.tools + focal = tools.find { |t| t.name == "json_schema_2020_12_tool" } + abort("Tool json_schema_2020_12_tool not found") unless focal + client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema }) when "tools_call" tools = client.tools add_numbers = tools.find { |t| t.name == "add_numbers" } diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index e26c20e..5c1dbec 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -8,9 +8,9 @@ client: # are not implemented in `MCP::Client::HTTP` yet. - http-custom-headers - http-invalid-tool-headers - # Client-side JSON Schema preservation gaps: `$ref` values must reach the caller unresolved. + # Client-side JSON Schema handling: `$ref` values must reach the caller unresolved, + # and the conformance client does not run the scenario over the modern lifecycle yet. - json-schema-ref-no-deref - - json-schema-2020-12-preservation # Unimplemented authorization extensions. - auth/dpop - auth/dpop-nonce From 27044b00472fc51c5b4ce41afbc93ebe35d7b301 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 13 Aug 2026 00:10:27 +0900 Subject: [PATCH 195/247] Add a `resources_list_handler` for context-dependent resource lists ## Motivation and Context `resources/read` can be taken over with `resources_read_handler`, but `resources/list` had no equivalent: it was bound to the internal `list_resources`, which paginates the constructor-provided `resources` array, and `define_custom_method` refuses to rebind a method the server already handles. An application whose visible resources depend on the request - authenticated versus anonymous sessions, or an OAuth-scoped connector that must see a scope-filtered set - had to build a fresh `Server` per request or override a private method. Both reference SDKs let the list vary per request. The TypeScript SDK exposes `resources/list` through `setRequestHandler` and gives resource templates a per-context `listCallback(ctx)`; the Python SDK takes an `on_list_resources` handler. This adds the Ruby equivalent. `resources_list_handler(&block)` registers a block that returns the resource collection to serve. It feeds `list_resources` rather than replacing it, so the framework still paginates the returned array and stamps the SEP-2549 cache hints; the block returns only the array, matching how `resources_read_handler` returns only the contents and the framework wraps them. A block that declares a `server_context:` keyword receives an `MCP::ServerContext`, reusing the same opt-in rule the read handler uses, so the list can be filtered by the authenticated principal or granted scope. The block is invoked once per page and must return a stable ordering across the pages of one query, since the cursor is a positional offset. When no handler is set, the constructor-provided resources are served unchanged. The `resources` capability is advertised whether or not the constructor array is populated, so a server that provides only a `resources_list_handler` still answers `resources/list`. Fixes #507. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` cover a handler replacing the served collection, a handler receiving `server_context:` and returning different sets for authenticated and anonymous requests, pagination and cache hints still applying to the handler-provided array, and a server with no constructor-provided resources served entirely by the handler. The existing `resources/list` test, which exercises the no-handler default path, is unchanged. ## Breaking Changes None. When no `resources_list_handler` is set, `resources/list` serves the constructor-provided resources exactly as before. --- README.md | 16 +++++++++++++- lib/mcp/server.rb | 37 +++++++++++++++++++++++++++++-- test/mcp/server_test.rb | 48 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0d7a527..d168113 100644 --- a/README.md +++ b/README.md @@ -1244,6 +1244,20 @@ end otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces the default `resources/read` handling, including the automatic routing to class-based resources described above. +To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, +so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array +and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. +A block that declares `server_context:` receives it: + +```ruby +server.resources_list_handler do |params, server_context:| + server_context[:authenticated] ? real_resources : demo_resources +end +``` + +The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset +into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. + For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) carrying the requested URI in the error `data` member: @@ -1613,7 +1627,7 @@ Client-initiated cancellation is also supported: see [Client-Side: Cancelling an #### Server-Side: Handlers that Check for Cancellation Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, -`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`, +`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, and `define_custom_method` blocks - receives an `MCP::ServerContext` wired to the in-flight request's cancellation token. Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651..4d8702a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -182,6 +182,7 @@ module MCP @resources = resources @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) + @resources_list_handler = nil @server_context = server_context self.page_size = page_size self.ttl_ms = ttl_ms @@ -387,6 +388,22 @@ module MCP @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end + # Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as + # the authenticated principal or granted scope. The block returns the resource collection to serve; + # the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources, + # so the block returns only the array, not the paginated result. + # A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set, + # the constructor-provided `resources` array is served unchanged. + # + # The block is invoked once per page, so it must return a stable ordering across the pages of one logical query; + # the cursor is a positional offset into the returned collection. + # + # @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared. + # @yieldreturn [Array] The resources to paginate. + def resources_list_handler(&block) + @resources_list_handler = block + end + # Sets a custom handler for `resources/read` requests. # The block receives the parsed request params and should return resource # contents. The return value is set as the `contents` field of the response. @@ -1088,12 +1105,28 @@ module MCP call_prompt_template_with_args(prompt, prompt_args, server_context) end - def list_resources(request) - page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + def list_resources(request, server_context: nil) + resources = if @resources_list_handler + invoke_resources_list_handler(request, server_context) + else + @resources + end + + page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end + # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in + # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies). + def invoke_resources_list_handler(request, server_context) + if handler_declares_server_context?(@resources_list_handler) + @resources_list_handler.call(request, server_context: server_context) + else + @resources_list_handler.call(request) + end + end + # Default `resources/read` handler: routes to class-based resources and resource templates. # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered, # unknown URIs keep the historical no-op `[]` response instead of raising. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 23accea..0b82815 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1356,6 +1356,54 @@ module MCP assert_instrumentation_data({ method: "resources/list" }) end + test "#resources_list_handler replaces the served resource collection" do + other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain") + @server.resources_list_handler { |_params| [other] } + + response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal({ resources: [other.to_h] }, response[:result]) + end + + test "#resources_list_handler receives server_context when it opts in" do + real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain") + demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain") + @server.resources_list_handler do |_params, server_context:| + server_context[:authenticated] ? [real] : [demo] + end + + @server.server_context = { authenticated: false } + anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + @server.server_context = { authenticated: true } + authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal({ resources: [demo.to_h] }, anon[:result]) + assert_equal({ resources: [real.to_h] }, authed[:result]) + end + + test "#resources_list_handler paginates and stamps cache hints on the returned collection" do + first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain") + second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain") + server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000) + server.resources_list_handler { |_params| [first, second] } + + response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal([first.to_h], response[:result][:resources]) + assert_equal("1", response[:result][:nextCursor]) + assert_equal(60_000, response[:result][:ttlMs]) + end + + test "#resources_list_handler serves a server with no constructor-provided resources" do + only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain") + server = Server.new(name: @server_name, resources: []) + server.resources_list_handler { |_params| [only] } + + response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal({ resources: [only.to_h] }, response[:result]) + end + test "#handle resources/read returns an empty array of contents by default" do request = { jsonrpc: "2.0", From 8c2e89afd52fc4a3f7824c6217b02106c4fff912 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 13 Aug 2026 00:47:39 +0900 Subject: [PATCH 196/247] Pass a handler-returned `_meta` through the subscribe result ## Motivation and Context `resources/subscribe` and `resources/unsubscribe` answered with a hardcoded `{}`, discarding whatever the registered handler returned. A server had no way to attach anything to the response - not even `_meta`, which the specification allows on every result. The handler docstring justified the discard as "always an empty result `{}` per the MCP specification", but the spec does not require an empty object here: the subscribe result is an `EmptyResult`, and every result may carry `_meta`. The one field that is interoperable is `_meta`. The TypeScript SDK validates the subscribe result against `EmptyResultSchema.strict()`, which rejects any member other than `_meta`, so a top-level field like a bespoke `subscriptionId` would be refused by a TypeScript client. The Python SDK passes the handler's `EmptyResult` return through, `_meta` included. Passing `_meta` matches both while staying within what the spec defines. The subscribe and unsubscribe handlers now pass a returned `_meta` hash through to the result and drop anything else. `subscription_result` reads `_meta` under a symbol or string key and includes it only when it is itself a hash; every other return shape - a non-hash, or a hash without a usable `_meta` - keeps the empty `{}` result it produced before. Advisory data such as a subscription identifier goes under `_meta`, namespaced, rather than at the top level. This affects only 2025-11-25 and earlier connections, since `resources/subscribe` and `resources/unsubscribe` are removed from the modern lifecycle. Fixes #508. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` cover a `_meta` hash passing through on both `resources/subscribe` and `resources/unsubscribe`, a non-`_meta` field being dropped, a string `_meta` key working, a non-hash `_meta` being ignored, and a non-hash return keeping the empty result. The existing subscribe and unsubscribe tests, which return `{}` and assert an empty result, are unchanged. ## Breaking Changes None. A handler that does not return a hash carrying a `_meta` hash - which is every handler written against the previous "return value is ignored" contract - still produces the empty `{}` result. --- README.md | 12 +++++++ lib/mcp/server.rb | 31 +++++++++++++---- test/mcp/server_test.rb | 74 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d7a527..2b656e7 100644 --- a/README.md +++ b/README.md @@ -1493,6 +1493,18 @@ server.define_tool(name: "update_resource") do |server_context:, **args| end ``` +The `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows +alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it +returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` +rather than returning it at the top level, which interoperating clients reject: + +```ruby +server.resources_subscribe_handler do |params| + id = subscriptions.create(params[:uri].to_s) + { _meta: { "myapp.example/subscriptionId" => id } } +end +``` + ### Sampling The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651..d4d73cf 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -407,19 +407,25 @@ module MCP end # Sets a custom handler for `resources/subscribe` requests. - # The block receives the parsed request params. The return value is - # ignored; the response is always an empty result `{}` per the MCP specification. + # The block receives the parsed request params. The response is an empty result, except that + # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, + # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data + # under `_meta`. # # @yield [params] The request params containing `:uri`. + # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. def resources_subscribe_handler(&block) @handlers[Methods::RESOURCES_SUBSCRIBE] = block end # Sets a custom handler for `resources/unsubscribe` requests. - # The block receives the parsed request params. The return value is - # ignored; the response is always an empty result `{}` per the MCP specification. + # The block receives the parsed request params. The response is an empty result, except that + # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, + # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data + # under `_meta`. # # @yield [params] The request params containing `:uri`. + # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. def resources_unsubscribe_handler(&block) @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block end @@ -621,8 +627,9 @@ module MCP contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) - {} + handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + + subscription_result(handler_result) when Methods::TOOLS_CALL call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::PROMPTS_GET @@ -906,6 +913,18 @@ module MCP end end + # The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta` + # every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`, + # which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns + # anything else keeps the empty `{}` result it had before, so returning a subscription identifier or + # other advisory data means nesting it under `_meta`. + def subscription_result(handler_result) + return {} unless handler_result.is_a?(Hash) + + meta = handler_result[:_meta] || handler_result["_meta"] + meta.is_a?(Hash) ? { _meta: meta } : {} + end + def validate_initialize_params!(params) unless params.is_a?(Hash) raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 23accea..f1051d7 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3623,6 +3623,80 @@ module MCP assert_equal "Invalid params", response[:error][:message] end + # Builds an initialized server that advertises the `resources.subscribe` capability. + def subscription_server + server = Server.new(name: "test_server", capabilities: { resources: { subscribe: true } }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + server + end + + # Sends `method` (`resources/subscribe` or `resources/unsubscribe`) and returns the JSON-RPC result. + def handle_subscription(server, method) + server.handle({ + jsonrpc: "2.0", + id: 2, + method: method, + params: { uri: "https://example.com/resource" }, + })[:result] + end + + test "#handle resources/subscribe passes a handler-returned _meta through to the result" do + server = subscription_server + server.resources_subscribe_handler { |_params| { _meta: { "acme.example/subscriptionId" => "sub-1" } } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({ _meta: { "acme.example/subscriptionId" => "sub-1" } }, result) + end + + test "#handle resources/unsubscribe passes a handler-returned _meta through to the result" do + server = subscription_server + server.resources_unsubscribe_handler { |_params| { _meta: { "acme.example/note" => "gone" } } } + + result = handle_subscription(server, "resources/unsubscribe") + + assert_equal({ _meta: { "acme.example/note" => "gone" } }, result) + end + + test "#handle resources/subscribe drops a handler-returned field that is not _meta" do + # The spec's result defines no member other than `_meta`, so a top-level field the handler adds is not + # a subscription protocol; it stays out of the response. + server = subscription_server + server.resources_subscribe_handler { |_params| { subscriptionId: "sub-1" } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + + test "#handle resources/subscribe accepts a string _meta key from the handler" do + server = subscription_server + server.resources_subscribe_handler { |_params| { "_meta" => { "k" => "v" } } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({ _meta: { "k" => "v" } }, result) + end + + test "#handle resources/subscribe ignores a handler-returned _meta that is not a hash" do + server = subscription_server + server.resources_subscribe_handler { |_params| { _meta: "not-a-hash" } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + + test "#handle resources/subscribe keeps an empty result when the handler returns a non-hash" do + server = subscription_server + server.resources_subscribe_handler { |_params| nil } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + test "#handle resources/subscribe without uri does not invoke a custom handler" do server = Server.new( name: "test_server", From bd702d3e51499a2e5187f36f32a7acebe2063581 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 13 Aug 2026 01:14:51 +0900 Subject: [PATCH 197/247] Reject modern-removed methods before the connection era locks ## Motivation and Context SEP-2575 removes `initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, and `resources/unsubscribe` from the modern lifecycle, and the server answers them with `-32601` there. The check consulted `ServerSession#era` alone, but `StdioTransport` locks the era in `lock_modern_era_on_success`, which runs after a response is produced. The first frame of a stdio connection therefore reached the server with `era == nil`, and a request carrying the modern `_meta` envelope was served instead of refused. All five methods leaked, one request per connection: ``` initialize a full InitializeResult ping {resultType: "complete"} logging/setLevel {resultType: "complete"} resources/subscribe {resultType: "complete"} resources/unsubscribe {resultType: "complete"} ``` `initialize` is the worst of the five because it also mutates the session: serving it negotiated the legacy lifecycle for a request that had declared the modern one, pinning the connection to `:legacy` and marking it initialized. The Streamable HTTP transport was never affected, since it routes sessionless modern traffic to `handle_modern` and rejects a modern envelope on every other path. The era is the wrong thing to consult on its own: a request declares which lifecycle it belongs to through its own envelope, independently of connection state that settles later. The Python SDK gates on exactly that, testing each request's `_meta` before dispatch rather than a negotiated connection mode. `modern_request?` now does the same here, treating a locked `:modern` era as authoritative and falling back to the request's envelope while the era is still undetermined. A legacy-locked session is left alone: `lift_request_envelope` already answers a modern envelope there with the lifecycle violation, which names the cause better than Method not found. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` cover all five removed methods being refused with `-32601` when they carry the envelope on an era-less session, an enveloped `initialize` no longer locking the era or marking the session initialized, a method the modern lifecycle keeps still being served under the same conditions, and a legacy `initialize` negotiating 2026-07-28 still succeeding and locking `:legacy` - negotiating a dual-era version through the handshake is not the same as carrying the envelope. A test in `test/mcp/server/transports/stdio_transport_test.rb` drives the case end to end, sending an enveloped `resources/subscribe` as a connection's first frame. One existing assertion changed. "modern results carry resultType complete" used `ping` as one of its stamped examples, which only passed because of this defect; `ping` is absent from the 2026-07-28 schema, so a modern request naming it is refused rather than stamped. It now uses `prompts/list`, with a note on why `ping` cannot appear there. The existing legacy counterpart, which pings without an envelope, is unchanged and still expects no stamp. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance suite exercises the HTTP transport, which this does not change, so its baseline is unaffected. ## Breaking Changes None for a conforming client. A request that both carries the 2026-07-28 envelope and names a method that revision removed now receives `-32601` instead of a result, which is the answer the spec already required once the era was known. --- lib/mcp/server.rb | 23 ++++++-- .../server/transports/stdio_transport_test.rb | 23 ++++++++ test/mcp/server_test.rb | 54 ++++++++++++++++++- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651..c06954f 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -565,9 +565,9 @@ module MCP end # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found - # there regardless of the envelope or the server's declared capabilities: in this era the method - # does not exist at all, which is why the check precedes `ensure_capability!`. - if session.respond_to?(:era) && session.era == :modern && Methods::MODERN_REMOVED_METHODS.include?(method) + # there regardless of the server's declared capabilities: in this era the method does not exist + # at all, which is why the check precedes `ensure_capability!`. + if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session) raise RequestHandlerError.new( "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)", request, @@ -677,6 +677,23 @@ module MCP } end + # Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it. + # A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is + # the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds, + # which would otherwise let the first request of a connection reach a method the modern lifecycle does not have. + # The Python SDK gates the same way, on the envelope of each request rather than on connection state that + # is only settled afterwards. + # + # A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there + # with the lifecycle violation, which names the cause better than Method not found. + def modern_request?(request, session) + era = session.respond_to?(:era) ? session.era : nil + return true if era == :modern + return false unless era.nil? + + RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil) + end + # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 699bcb7..bb599d3 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -598,6 +598,29 @@ module MCP assert_equal :modern, session_era end + test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do + # The era locks only after a response succeeds, so this frame reaches the server unlocked. + # SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern. + responses = run_transport_session([ + { + jsonrpc: "2.0", + method: "resources/subscribe", + id: 1, + params: { + uri: "https://example.invalid/resource", + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }, + ]) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) + refute_equal :legacy, session_era + end + test "does not lock an era when the era-distinctive request fails" do # An unsupported envelope version fails with -32022, so the connection stays unlocked # and a legacy initialize can still succeed afterwards. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 23accea..b5ed1ec 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -374,6 +374,56 @@ module MCP end end + test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do + # `StdioTransport` locks the era only after a response succeeds, so the first request of a connection + # arrives with `era == nil`. The envelope on the request is what identifies it as modern there. + Methods::MODERN_REMOVED_METHODS.each do |method| + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle(modern_request(method, {}), session: session) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + end + end + + test "#handle does not let an enveloped initialize lock the era before it is settled" do + # Serving it would negotiate the legacy lifecycle for a request that declared the modern one, + # pinning the connection to the wrong era for its whole lifetime. + session = ServerSession.new(server: @server, transport: mock) + + @server.handle(modern_request("initialize", initialize_params), session: session) + + assert_nil session.era + refute_predicate session, :initialized? + end + + test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle(modern_request("tools/list", {}), session: session) + + refute_nil response[:result] + end + + test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do + # Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope: + # the handshake still belongs to the legacy lifecycle and locks that era. + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2026-07-28"), + }, + session: session, + ) + + assert_equal "2026-07-28", response.dig(:result, :protocolVersion) + assert_equal :legacy, session.era + end + test "#handle server/discover requires the envelope on a modern-locked session" do # The 2026-07-28 conformance requirements reject an envelope-less `server/discover` # on the modern era with -32602 (SEP-2575). @@ -4255,9 +4305,11 @@ module MCP test "modern results carry resultType complete" do server = Server.new(name: "result_type_test", tools: [result_type_tool]) + # Every method here has to be one the modern lifecycle still has. `ping` does not qualify: + # SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result. [ modern_request(Methods::TOOLS_LIST, {}), - modern_request(Methods::PING, {}), + modern_request(Methods::PROMPTS_LIST, {}), modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }), ].each do |request| response = server.handle(request) From aaf0c71995c54397e453927764b048ae1d6c7862 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 29 Jul 2026 18:48:50 +0900 Subject: [PATCH 198/247] Restrict OAuth Discovery URL Destinations ## Motivation and Context The OAuth client checked the scheme of every discovery URL but never checked where it pointed. `Discovery.secure_url?` returns true for any `https` URL regardless of host, and the `resource_metadata` URL from a `WWW-Authenticate` challenge is placed first in the candidate list, so a server answering `401 Bearer resource_metadata="https://169.254.169.254/..."` had the client issue that request during discovery, before the user approves anything. The same gap applied one hop deeper: a server that serves its own Protected Resource Metadata can name any host in `authorization_servers`, and the endpoints in the document fetched from there receive the dynamic client registration and the token request that carries the client credentials. Two rules now apply to those URLs. Both run before the request is sent, and neither is configurable. The `resource_metadata` URL must be on the MCP server's own origin. Protected Resource Metadata describes that server, so a real deployment publishes it there, and requiring it means a challenge cannot aim the first request of the flow at an unrelated host. RFC 9728 does not itself require this, so the SDK is deliberately stricter than the specification: the range check below cannot recognize an internal host that is named rather than addressed, and this is the one URL a server supplies directly. The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be IP literals in a private, loopback, link-local, or unique-local range, which is the precaution RFC 9728 Section 7.7 asks clients to take. That check is skipped when the configured MCP server URL is itself on such an address. Pointing a client at a private network is a deliberate act and its authorization server usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. Only a server reachable on the public internet is barred from steering the client inward. A range check is only as good as its reading of the address, so the second rule also refuses hosts written in the numeric IPv4 grammar `inet_aton` accepts and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, so reading only the dotted-decimal spelling would refuse `169.254.169.254` while admitting every alias of it. Those hosts are refused outright rather than decoded and range-checked, because their value is resolver-dependent: a leading zero reads as octal on one platform and decimal on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. Choosing a reading here would leave whichever one was not taken as a way through, and nothing is legitimately addressed in this notation. Hostnames are not resolved. A lookup here would be a second network request driven by the same untrusted input, and the address it returned need not be the one the HTTP client connects to a moment later, so the check would read as a guarantee it cannot make. `MCP::Client::OAuth::Flow` also documents why its Faraday connection has no redirect-following middleware: every check runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. ## How Has This Been Tested? - New unit tests in `test/mcp/client/oauth/discovery_test.rb` cover `same_origin?` (case-insensitive scheme and host, explicit default ports, differing port/host/scheme, subdomains, malformed and hostless input) and `private_network_host?` (every blocked range, IPv4-mapped IPv6 spellings, bracketed IPv6, `localhost` matched exactly, and public addresses and names). One test documents that a named internal host reads as public, which is the deliberate limit of the predicate. - Further unit tests cover the numeric spellings: packed integers, hexadecimal, and short forms that reach a blocked range, and the ambiguous leading-zero forms whose value depends on the resolver. Removing the rule leaves them addressable, which is what those tests fail on. - New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/http_oauth_test.rb` drive both rules through `run!`, `refresh!`, and the transport. They assert with `assert_not_requested` that no request was issued rather than only that an error was raised, since nothing downstream can un-send one. One of them names the authorization server `https://0xa9fea9fe`, which without the rule above is fetched. A further test runs a complete flow with the MCP server and the authorization server on two loopback ports, which is what the carve-out exists for. - `rake conformance` passes with all 22 auth scenarios green. Its harness runs the MCP server and the authorization server on separate loopback ports and publishes PRM on the server's own origin, so it exercises exactly the shapes these rules must not break. ## Breaking Changes A client whose MCP server is reachable on the public internet no longer follows a `resource_metadata` URL on a different origin, and no longer contacts an authorization server or OAuth endpoint at a private IP literal or at a host written in the numeric IPv4 notation above. All were previously allowed. Local development, private-network deployments, and the conformance suite are unaffected. --- README.md | 22 +++ lib/mcp/client/oauth/discovery.rb | 122 ++++++++++-- lib/mcp/client/oauth/flow.rb | 102 +++++++++- test/mcp/client/oauth/discovery_test.rb | 115 ++++++++++++ test/mcp/client/oauth/flow_test.rb | 230 ++++++++++++++++++++++- test/mcp/client/oauth/http_oauth_test.rb | 37 ++++ 6 files changed, 601 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 0d7a527..a0276d8 100644 --- a/README.md +++ b/README.md @@ -2920,6 +2920,28 @@ whether the swap was triggered by `instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, and whether the new URL is `http://` *or* `https://` to a different host or tenant. +##### Discovery URL Destinations + +The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations. +Both checks run before the request is sent, and neither is configurable. + +- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, + so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, + which does not require it. +- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be + IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). + The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`, + and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. +- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, + and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. + +The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed, +such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one +the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly. + +If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against +the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. + #### Customizing the Faraday Connection You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index 885f1db..04030cc 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -8,7 +8,7 @@ module MCP module OAuth # Stateless helpers that map MCP-authorization spec URLs and headers into something # the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on. - # The module bundles five concerns that share no state but are closely related to + # The module bundles six concerns that share no state but are closely related to # the spec's "Discovery" and "Communication Security" sections: # # - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls @@ -22,6 +22,10 @@ module MCP # - **Communication Security check** (`secure_url?`): enforces "HTTPS only" # for every OAuth-facing URL, with the loopback carve-out described in # `secure_url?`'s comment. + # - **Destination checks** (`same_origin?`, `private_network_host?`): + # answer *where* a URL points, which the scheme alone does not. `Flow` uses + # them to refuse server-supplied discovery URLs aimed at hosts the MCP server + # has no business steering the client toward. # - **URL canonicalization** (`canonicalize_url`): normalizes scheme, # host, port, path, percent-encoded dot segments, and fragments # so two URLs that *refer to the same resource* compare as equal, @@ -193,6 +197,51 @@ module MCP false end + # Returns true when `url` and `other` share an origin: same scheme, same host, + # and same port. Scheme and host compare case-insensitively, and an implicit default port compares + # equal to the same port written out, because `URI::HTTP#port` already resolves to the default. + # + # Anything that fails to parse or carries no host returns false, so a caller using this as + # a security gate refuses rather than admits on malformed input. + def same_origin?(url, other) + uri = URI.parse(url.to_s) + other_uri = URI.parse(other.to_s) + return false if uri.host.nil? || uri.host.empty? + return false if other_uri.host.nil? || other_uri.host.empty? + + uri.scheme&.downcase == other_uri.scheme&.downcase && + uri.host.downcase == other_uri.host.downcase && + uri.port == other_uri.port + rescue URI::InvalidURIError + false + end + + # Returns true when `host` is an IP literal that is not reachable from the public internet, + # or the `localhost` name. The ranges are the ones RFC 9728 Section 7.7 and the MCP security best practices name + # as SSRF targets, so `169.254.0.0/16` covers the `169.254.169.254` cloud metadata address that motivates the check. + # + # Hostnames are deliberately NOT resolved. A lookup here would be a second network request driven by + # the same untrusted input, and the address it returned could differ from the one the HTTP client connects to + # a moment later, so the check would read as a guarantee it cannot make. That leaves names like `vault.corp.internal` + # out of reach of this predicate; it is one layer of an SSRF defense, not the whole of one. + def private_network_host?(host) + return false if host.nil? || host.empty? + + normalized = host.downcase + return true if normalized == "localhost" + + literal = normalized.delete_prefix("[").delete_suffix("]") + address = parse_ip_address(literal) + return numeric_ipv4_spelling?(literal) unless address + + # `::ffff:169.254.169.254` addresses the same interface as `169.254.169.254`, + # so compare the IPv4 form rather than letting the mapped spelling through. + address = address.native if address.ipv6? && address.ipv4_mapped? + + ranges = address.ipv4? ? PRIVATE_IPV4_RANGES : PRIVATE_IPV6_RANGES + ranges.any? { |range| range.include?(address) } + end + # Returns true when `url` satisfies the structural requirements for # a Client ID Metadata Document URL per the MCP 2025-11-25 # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`. @@ -287,20 +336,17 @@ module MCP uri.to_s end - # Returns true when `prm` (a PRM `resource` URL) covers `server` - # (the MCP endpoint URL): same scheme/host/port, with PRM's path being - # a prefix of the server's path. When PRM also advertises a query - # string, the server's query MUST be identical to it - # (otherwise a hijacked PRM that advertises `?tenant=evil` would cover - # an MCP server at `?tenant=victim` and let the attacker mint - # a different tenant's token for the same origin + path). - # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier - # over the origin + path prefix and covers any server query. + # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL): + # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM + # also advertises a query string, the server's query MUST be identical to it (otherwise + # a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim` + # and let the attacker mint a different tenant's token for the same origin + path). + # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over + # the origin + path prefix and covers any server query. # - # An empty query (`prm_url?` -- URI#query returns `""`) is NOT - # treated as wildcard: it represents the URI literally `<...>?`, - # which is distinct from "no query at all" and from any non-empty query, - # so it must match exactly. + # An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard: + # it represents the URI literally `<...>?`, which is distinct from "no query at all" + # and from any non-empty query, so it must match exactly. # # Both arguments must already be canonicalized. def resource_covers?(prm:, server:) @@ -340,6 +386,29 @@ module MCP IPV6_LOOPBACK = IPAddr.new("::1") private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK + # Backing ranges for `private_network_host?`. + # + # `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT + # space; neither is a destination a legitimate authorization server publishes, + # and both are reachable enough from a client's network position to be worth + # refusing. `::/96` covers the unspecified address, IPv6 loopback, and + # the deprecated IPv4-compatible form (`::10.0.0.1`) in one range. + PRIVATE_IPV4_RANGES = [ + IPAddr.new("0.0.0.0/8"), + IPAddr.new("10.0.0.0/8"), + IPAddr.new("100.64.0.0/10"), + IPAddr.new("127.0.0.0/8"), + IPAddr.new("169.254.0.0/16"), + IPAddr.new("172.16.0.0/12"), + IPAddr.new("192.168.0.0/16"), + ].freeze + PRIVATE_IPV6_RANGES = [ + IPAddr.new("::/96"), + IPAddr.new("fc00::/7"), + IPAddr.new("fe80::/10"), + ].freeze + private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES + def loopback_host?(host) return false if host.nil? || host.empty? @@ -362,6 +431,31 @@ module MCP nil end + # Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`), + # octal (`0177`), or decimal (`127`). + INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze + private_constant :INET_ATON_PART + + # Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts + # and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or + # decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and + # `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, + # so a check that understood only the canonical form would refuse `169.254.169.254` + # while waving through every other spelling of it. + # + # Such a host is refused outright rather than decoded and range-checked, because its + # value is resolver-dependent: a leading zero reads as octal on one platform and decimal + # on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. + # Deciding here which one it meant would leave whichever reading was not taken as a way + # through. No authorization server is legitimately addressed in this notation, so + # refusing the grammar entirely costs nothing and leaves no spelling to miss. + def numeric_ipv4_spelling?(host) + parts = host.split(".", -1) + return false if parts.empty? || parts.length > 4 + + parts.all? { |part| INET_ATON_PART.match?(part) } + end + # A redirect URI counts as native when it uses a custom non-http(s) scheme # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is # a loopback address. A URI without a scheme or one that fails to parse is not native. diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 32669f2..88413a3 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -33,9 +33,15 @@ module MCP def run!(server_url:, resource_metadata_url: nil, scope: nil) # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very # first thing we contact in the OAuth flow, so it has to clear the same - # Communication Security bar as the OAuth endpoints downstream. + # Communication Security bar as the OAuth endpoints downstream, and it has to + # point back at the server that issued the challenge. if resource_metadata_url ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + ensure_same_origin!( + resource_metadata_url, + label: "WWW-Authenticate resource_metadata URL", + server_url: server_url, + ) end prm, authorization_server = locate_authorization_server( @@ -49,7 +55,11 @@ module MCP # being redirected to credentials minted for a different audience. resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) + as_metadata = authorization_server_metadata( + authorization_server: authorization_server, + legacy: prm.nil?, + server_url: server_url, + ) case provider_authorization_flow when :client_credentials @@ -212,6 +222,11 @@ module MCP if resource_metadata_url ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + ensure_same_origin!( + resource_metadata_url, + label: "WWW-Authenticate resource_metadata URL", + server_url: server_url, + ) end prm, authorization_server = locate_authorization_server( @@ -221,7 +236,11 @@ module MCP resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) + as_metadata = authorization_server_metadata( + authorization_server: authorization_server, + legacy: prm.nil?, + server_url: server_url, + ) client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, @@ -296,6 +315,11 @@ module MCP if prm authorization_server = first_authorization_server(prm) ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") + ensure_routable_destination!( + authorization_server, + label: "PRM `authorization_servers` entry", + server_url: server_url, + ) [prm, authorization_server] else authorization_base = server_origin!(server_url) @@ -311,7 +335,7 @@ module MCP # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path). # When even the metadata document is absent, the legacy spec's default endpoints are used. - def authorization_server_metadata(authorization_server:, legacy:) + def authorization_server_metadata(authorization_server:, legacy:, server_url:) metadata = if legacy begin fetch_authorization_server_metadata(issuer_url: authorization_server) @@ -324,7 +348,7 @@ module MCP end end - ensure_secure_endpoints!(metadata) + ensure_secure_endpoints!(metadata, server_url: server_url) metadata end @@ -445,13 +469,72 @@ module MCP "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)." end - def ensure_secure_endpoints!(as_metadata) + # Requires a URL the *server* chose to sit on the origin the *caller* chose. + # + # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is + # published on that server's own origin. Without this check a `WWW-Authenticate` challenge + # can aim the first request of the flow at any host the client can route to: the URL arrives + # from the network, it is fetched before the user approves anything, and the `resource` check that + # runs afterwards cannot un-send the request. + # + # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than + # the specification. It is enforced unconditionally because no known deployment publishes its PRM + # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see + # internal hosts that are named rather than addressed. + # https://www.rfc-editor.org/rfc/rfc9728#section-7.7 + def ensure_same_origin!(url, label:, server_url:) + return if Discovery.same_origin?(url, server_url) + + raise AuthorizationError, + "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \ + "#{sanitized_url(server_url).inspect}; refusing to fetch it." + end + + # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address, + # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take. + # + # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address, + # the whole flow is already inside that network and the authorization server legitimately lives there too. + # That covers `http://localhost` development, the conformance harness (which runs the MCP server and + # the authorization server on two loopback ports), and deployments that never leave a corporate network. + # Only a server reachable on the public internet is barred from steering the client inward. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + def ensure_routable_destination!(url, label:, server_url:) + return unless private_network_url?(url) + return if private_network_url?(server_url) + + raise AuthorizationError, + "#{label} #{sanitized_url(url).inspect} points into a private network range, " \ + "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it." + end + + def ensure_secure_endpoints!(as_metadata, server_url:) ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key| endpoint = as_metadata[key] - ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint + next unless endpoint + + ensure_secure_url!(endpoint, label: "Authorization server #{key}") + ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url) end end + # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse, + # so the rescue here is a backstop rather than a leniency. + def private_network_url?(url) + Discovery.private_network_host?(URI.parse(url.to_s).host) + rescue URI::InvalidURIError + false + end + + # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes + # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in + # every log destination the error passes through. + def sanitized_url(url) + Discovery.canonicalize_origin_and_path(url) + rescue URI::Error + url.to_s + end + # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be # identical (literal byte-for-byte equality, no normalization) to # the issuer URL the client used to discover that document. This guards @@ -962,6 +1045,11 @@ module MCP @http_client ||= @http_client_factory.call end + # Deliberately built without redirect-following middleware. Every destination check in + # this class runs against the URL as written, before the request goes out, so a connection + # that transparently followed a `3xx` would let a server reach a host the checks just refused. + # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here + # and the guards above only cover the first hop. def default_http_client require "faraday" Faraday.new do |faraday| diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index 4b29184..c8d9fda 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -437,6 +437,121 @@ module MCP refute(Discovery.secure_url?("not a url")) end + def test_same_origin_accepts_matching_scheme_host_and_port + assert(Discovery.same_origin?("https://srv.example.com/.well-known/x", "https://srv.example.com/mcp")) + assert(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com:8443/b")) + assert(Discovery.same_origin?("http://localhost:9292/a", "http://localhost:9292/mcp")) + assert(Discovery.same_origin?("https://srv.example.com/a?q=1#f", "https://srv.example.com/mcp")) + end + + def test_same_origin_ignores_case_in_scheme_and_host + assert(Discovery.same_origin?("HTTPS://SRV.Example.COM/a", "https://srv.example.com/mcp")) + end + + def test_same_origin_treats_an_explicit_default_port_as_the_implicit_one + assert(Discovery.same_origin?("https://srv.example.com:443/a", "https://srv.example.com/mcp")) + assert(Discovery.same_origin?("http://srv.example.com:80/a", "http://srv.example.com/mcp")) + end + + def test_same_origin_rejects_differing_components + refute(Discovery.same_origin?("https://attacker.example.com/a", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://169.254.169.254/latest/", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("http://srv.example.com/a", "https://srv.example.com/mcp")) + # A subdomain is a different origin, so a hijacked challenge cannot walk + # sideways into a sibling host that shares the registrable domain. + refute(Discovery.same_origin?("https://evil.srv.example.com/a", "https://srv.example.com/mcp")) + end + + def test_same_origin_rejects_malformed_or_hostless_urls + refute(Discovery.same_origin?("not a url", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://srv.example.com/a", "not a url")) + refute(Discovery.same_origin?("https://", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?(nil, "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("/relative/path", "https://srv.example.com/mcp")) + end + + def test_private_network_host_recognizes_reserved_ipv4_ranges + assert(Discovery.private_network_host?("0.0.0.0")) + assert(Discovery.private_network_host?("10.0.0.5")) + assert(Discovery.private_network_host?("100.64.0.1")) + assert(Discovery.private_network_host?("127.0.0.1")) + # The cloud metadata address this check exists for. + assert(Discovery.private_network_host?("169.254.169.254")) + assert(Discovery.private_network_host?("172.16.0.1")) + assert(Discovery.private_network_host?("172.31.255.255")) + assert(Discovery.private_network_host?("192.168.1.1")) + end + + def test_private_network_host_recognizes_reserved_ipv6_ranges + assert(Discovery.private_network_host?("::")) + assert(Discovery.private_network_host?("::1")) + assert(Discovery.private_network_host?("[::1]")) + assert(Discovery.private_network_host?("fd00::1")) + assert(Discovery.private_network_host?("fe80::1")) + assert(Discovery.private_network_host?("[fe80::1]")) + end + + def test_private_network_host_unwraps_ipv4_mapped_ipv6 + # `::ffff:169.254.169.254` reaches the same interface as the IPv4 spelling, + # so the mapped form must not be a way around the ranges above. + assert(Discovery.private_network_host?("::ffff:169.254.169.254")) + assert(Discovery.private_network_host?("[::ffff:10.0.0.5]")) + assert(Discovery.private_network_host?("::ffff:127.0.0.1")) + refute(Discovery.private_network_host?("::ffff:93.184.216.34")) + end + + def test_private_network_host_refuses_non_canonical_ipv4_spellings + # `inet_aton` accepts forms `IPAddr` rejects, and the resolver reaches the same + # interfaces through them: `2130706433` and `127.1` are 127.0.0.1, and + # `0xa9fea9fe` is the cloud metadata address. Checking only the dotted-decimal + # spelling would refuse `169.254.169.254` and admit every alias of it. + assert(Discovery.private_network_host?("2130706433")) + assert(Discovery.private_network_host?("0x7f000001")) + assert(Discovery.private_network_host?("0xa9fea9fe")) + assert(Discovery.private_network_host?("127.1")) + assert(Discovery.private_network_host?("127.0.1")) + assert(Discovery.private_network_host?("0x7f.0.0.1")) + assert(Discovery.private_network_host?("0251.0376.0251.0376")) + end + + def test_private_network_host_refuses_numeric_spellings_whose_value_is_resolver_dependent + # `010.0.0.1` is 8.0.0.1 read as octal and 10.0.0.1 read as decimal, and platforms + # disagree on which it is. Both readings are refused rather than one being chosen, + # since the reading not taken would otherwise be a way through. + assert(Discovery.private_network_host?("010.0.0.1")) + assert(Discovery.private_network_host?("0177.0.0.1")) + + # The same applies to numeric spellings that land outside the blocked ranges, or + # nowhere at all: nothing is addressed this way on purpose. + assert(Discovery.private_network_host?("0x8080808")) + assert(Discovery.private_network_host?("256.1.1.1")) + end + + def test_private_network_host_recognizes_localhost_by_name_only + assert(Discovery.private_network_host?("localhost")) + assert(Discovery.private_network_host?("LOCALHOST")) + refute(Discovery.private_network_host?("foo.localhost")) + refute(Discovery.private_network_host?("localhost.attacker.com")) + end + + def test_private_network_host_rejects_public_addresses_and_names + refute(Discovery.private_network_host?("93.184.216.34")) + refute(Discovery.private_network_host?("8.8.8.8")) + refute(Discovery.private_network_host?("172.32.0.1")) + refute(Discovery.private_network_host?("2606:4700::1111")) + refute(Discovery.private_network_host?("srv.example.com")) + refute(Discovery.private_network_host?(nil)) + refute(Discovery.private_network_host?("")) + end + + def test_private_network_host_does_not_resolve_names + # Documents the deliberate limit of the predicate: an internal host that is + # named rather than addressed reads as public here. `same_origin?` is what + # keeps the `resource_metadata` URL from reaching one. + refute(Discovery.private_network_host?("vault.corp.internal")) + end + def test_client_id_metadata_document_url_accepts_https_with_path assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json")) assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm")) diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index f27679b..1cc248d 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,6 +68,23 @@ module MCP WebMock.reset! end + # A plain authorization-code provider for the destination-check tests. + # They all expect to fail before any redirect happens, so the handlers stay inert; + # a test that reached them would be asserting the wrong thing. + def ssrf_test_provider + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + end + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") ClientCredentialsProvider.new( client_id: "cc-client", @@ -1744,10 +1761,212 @@ module MCP assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message) end + def test_run_does_not_fetch_a_resource_metadata_url_off_the_server_origin + # A server that answers 401 chooses the `resource_metadata` URL, and discovery puts it first + # in the candidate list. Left unchecked, a single response header aims the client's first OAuth request + # at any host it can route to, including the cloud metadata address. The assertion that matters is not + # the raise but `assert_not_requested`: nothing downstream can un-send a request. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + assert_match(/resource_metadata/i, error.message) + assert_match(/origin/i, error.message) + end + + def test_run_does_not_fetch_an_https_resource_metadata_url_on_a_foreign_public_origin + # The scheme check alone passes this one: it is HTTPS, just not the server's. + metadata_url = "https://attacker.example.com/.well-known/oauth-protected-resource" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + end + + def test_refresh_does_not_fetch_a_resource_metadata_url_off_the_server_origin + # `refresh!` reads the same header on a 401 and must gate it identically; + # otherwise the token-refresh path stays reachable after `run!` is closed. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + provider = ssrf_test_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + end + + def test_run_does_not_contact_an_authorization_server_on_a_private_address + # One hop deeper than the `resource_metadata` URL: the server serves its own PRM from its own origin, + # then names a private address as its authorization server. Same-origin cannot see this one, + # so the range check has to. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: ["https://169.254.169.254"], + ), + ) + stub_request(:get, %r{\Ahttps://169\.254\.169\.254/}).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, %r{\Ahttps://169\.254\.169\.254/}) + assert_match(/private network/i, error.message) + end + + def test_run_does_not_contact_an_authorization_server_spelled_as_a_packed_integer + # `0xa9fea9fe` is 169.254.169.254 written for `inet_aton`. The connection resolves it to + # the same address, so the previous test's protection has to survive the rewrite. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: ["https://0xa9fea9fe"], + ), + ) + stub_request(:get, %r{\Ahttps://0xa9fea9fe/}).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, %r{\Ahttps://0xa9fea9fe/}) + assert_match(/private network/i, error.message) + end + + def test_run_does_not_post_to_a_token_endpoint_on_a_private_address + # The token endpoint receives the client credentials and the authorization code, + # so an AS document that relocates it inside the client's network is a credential disclosure, + # not just a probe. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "https://10.0.0.5/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "https://10.0.0.5/token").to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://10.0.0.5/token") + assert_match(/private network/i, error.message) + end + + def test_run_does_not_post_to_a_registration_endpoint_on_a_private_address + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "https://192.168.1.1/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "https://192.168.1.1/register").to_return(status: 201, body: "{}") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://192.168.1.1/register") + end + + def test_run_allows_a_private_authorization_server_when_the_mcp_server_is_also_private + # The shape the conformance harness runs and the shape local development takes: + # the MCP server and the authorization server sit on two loopback ports. + # Once the caller has pointed the client at a private address, the authorization server + # living there too is not an escalation, and refusing it would break every local setup. + server_url = "http://localhost:9292/mcp" + prm_url = "http://localhost:9292/.well-known/oauth-protected-resource/mcp" + auth_base = "http://localhost:9393" + + stub_request(:get, prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: server_url, authorization_servers: [auth_base]), + ) + stub_request(:get, "#{auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: auth_base, + authorization_endpoint: "#{auth_base}/authorize", + token_endpoint: "#{auth_base}/token", + registration_endpoint: "#{auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "#{auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "local-client"), + ) + stub_request(:post, "#{auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "local-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + result = Flow.new(provider: provider).run!(server_url: server_url, resource_metadata_url: prm_url) + + assert_equal(:authorized, result) + assert_equal("local-token", provider.access_token) + end + def test_run_rejects_when_as_metadata_issuer_does_not_match - # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal - # the discovery URL. A metadata document advertising a different issuer - # than the one PRM pointed at is treated as a confused-deputy attempt. + # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal the discovery URL. + # A metadata document advertising a different issuer than the one PRM pointed at is treated + # as a confused-deputy attempt. stub_request(:get, @as_metadata_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -1781,9 +2000,8 @@ module MCP end def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash - # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent - # after normalization". `https://auth.example.com` and - # `https://auth.example.com/` are different strings and MUST NOT match. + # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent after normalization". + # `https://auth.example.com` and `https://auth.example.com/` are different strings and MUST NOT match. stub_request(:get, @as_metadata_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 26d074d..73fcf04 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -101,6 +101,43 @@ module MCP assert_equal("test-token-after-flow", provider.access_token) end + def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin + # End to end over the transport, which is where the header is actually parsed: + # a server that answers 401 must not be able to name an unrelated host in + # `resource_metadata` and have the client fetch it. The request never leaving + # is the point; the 401 surfacing to the caller is secondary. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"), + }, + body: "", + ) + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(StandardError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_not_requested(:get, metadata_url) + end + def test_send_request_runs_step_up_flow_on_403_insufficient_scope # First call with the initial token: 403 carrying an `insufficient_scope` # Bearer challenge with the escalated scope. The transport must re-run From 416b7e8a6eb0f1606c687b0c34e6e6f55c01de3a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 11 Aug 2026 01:16:31 +0900 Subject: [PATCH 199/247] Bound server-to-client requests with a timeout ## Motivation and Context The specification asks implementations to establish timeouts for all sent requests, "to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes, and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input, and then simply never answers holds that worker thread for good. A handful of such requests exhausts a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it, and only the GET stream has a keepalive to notice a vanished peer. `StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds (`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through `ServerContext` and `ServerSession` down to the transport. The option spells out its direction because the same constructor already takes `max_request_bytes:` for the requests arriving the other way. Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default. On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`. Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests and its sessionless requests never register the session `send_request` looks up. Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here. The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline. It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown) unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it. The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter the same non-reentrant mutex. stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters `timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer as `notifications/cancelled`, a response arriving before the deadline still returning normally, and the constructor rejecting a non-positive `server_to_client_request_timeout`. `test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five `ServerSession` entry points, and being dropped for transports that never declared it. `test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:` not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection, cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new wait primitive. `bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`: the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve the same object without deadlocking. ## Breaking Changes Server-to-client requests that previously waited forever now fail after ten minutes by default. This is the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob. --- README.md | 16 +++ lib/mcp/server.rb | 22 ++++ lib/mcp/server/pending_response.rb | 62 ++++++++++ .../transports/streamable_http_transport.rb | 72 +++++++++-- lib/mcp/server_context.rb | 18 ++- lib/mcp/server_session.rb | 38 ++++-- .../streamable_http_transport_test.rb | 113 ++++++++++++++++++ test/mcp/server_context_test.rb | 35 ++++++ .../server_session_request_timeout_test.rb | 99 +++++++++++++++ 9 files changed, 449 insertions(+), 26 deletions(-) create mode 100644 lib/mcp/server/pending_response.rb create mode 100644 test/mcp/server_session_request_timeout_test.rb diff --git a/README.md b/README.md index 9a76585..a6d001f 100644 --- a/README.md +++ b/README.md @@ -1409,6 +1409,22 @@ Roots define the boundaries of where a server can operate, providing a list of d > 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. +**Timeouts:** every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely. +`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells +the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`, +which is the knob to reach for when a prompt legitimately waits on a person: + +```ruby +server_context.create_form_elicitation( + message: "Approve this deployment?", + requested_schema: { type: "object", properties: { approved: { type: "boolean" } } }, + timeout: 3600, # This one waits up to an hour. +) +``` + +`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering +surfaces as end-of-file rather than as a wait that never ends. + **Using Roots in Tools:** Tools that accept a `server_context:` parameter can call `list_roots` on it. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 824e9d3..ac2dc83 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -12,6 +12,7 @@ require_relative "server_context" require_relative "server/capabilities" require_relative "server/input_required_result" require_relative "server/pagination" +require_relative "server/pending_response" require_relative "server/request_state_security" require_relative "server/transports" @@ -125,6 +126,27 @@ module MCP end end + # Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout. + # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources, + # and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this. + # These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them. + # + # Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic + # internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not + # spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for + # this condition, so a client that already recognizes it there reads the same meaning here. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts + class RequestTimeoutError < RequestHandlerError + attr_reader :request_id, :timeout + + def initialize(message, request_id:, timeout:) + super(message, nil, error_type: :request_timeout, error_code: -32001) + @request_id = request_id + @timeout = timeout + end + end + class MethodAlreadyDefinedError < StandardError attr_reader :method_name diff --git a/lib/mcp/server/pending_response.rb b/lib/mcp/server/pending_response.rb new file mode 100644 index 0000000..a82ae07 --- /dev/null +++ b/lib/mcp/server/pending_response.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module MCP + class Server + # A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response + # and whichever thread resolves it (the client's response, a cancellation, or session teardown). + # + # `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7, + # so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue` + # this replaces, keeping the resolving call sites unchanged. + # + # First writer wins: a second `push` is ignored, so a cancellation that races a real response + # cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when + # the deadline passes with nothing pushed. + class PendingResponse + def initialize + @mutex = Mutex.new + @condition = ConditionVariable.new + @delivered = false + @value = nil + end + + # Resolves the wait. Ignored when a value was already delivered. + def push(value) + @mutex.synchronize do + next if @delivered + + @delivered = true + @value = value + @condition.broadcast + end + end + + # Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller + # on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously, + # so the deadline is re-checked against the monotonic clock. + # + # The expiry block runs after the lock is released: it typically cancels the request, + # which resolves this same object, and Ruby's `Mutex` is not reentrant. + def pop(timeout:) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + expired = false + + value = @mutex.synchronize do + until @delivered + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + if remaining <= 0 + expired = true + break + end + + @condition.wait(@mutex, remaining) + end + + @value + end + + expired ? yield : value + end + end + end +end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8ccc13f..e0ac867 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -52,6 +52,21 @@ module MCP UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT + # Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`). + # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust + # the sender's resources; without one, a client that opens a session and simply never replies parks + # a worker thread for good. + # + # Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds + # for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default + # is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation + # prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds + # nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler + # that legitimately waits longer passes `timeout:`. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts + DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600 + # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole # body into memory and parses it, so without a cap a single unauthenticated POST # can allocate gigabytes and OOM the worker. 4 MiB comfortably @@ -114,6 +129,9 @@ module MCP # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable # when an upstream proxy already keeps the stream alive. + # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its + # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. + # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. def initialize( server, stateless: false, @@ -126,7 +144,8 @@ module MCP session_request_validator: nil, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, - listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL + listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, + server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -193,6 +212,12 @@ module MCP @listen_keepalive_interval = listen_keepalive_interval + unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive? + raise ArgumentError, "server_to_client_request_timeout must be a positive number" + end + + @server_to_client_request_timeout = server_to_client_request_timeout + start_reaper_thread if @session_idle_timeout end @@ -442,14 +467,18 @@ module MCP end end - # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and - # blocks until the client responds. + # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until + # the client responds. # - # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`, - # sends the request via SSE stream, then blocks on `queue.pop`. - # When the client POSTs a response, `handle_response` matches it by `request_id` - # and pushes the result onto the queue, unblocking this thread. - def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil) + # Uses a `PendingResponse` for cross-thread synchronization: this method registers one, + # sends the request via SSE stream, then waits on it. When the client POSTs a response, + # `handle_response` matches it by `request_id` and resolves the pending response, + # unblocking this thread. A cancellation and session teardown resolve it the same way. + # + # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`), + # so a client that never answers cannot park the calling thread for good. On expiry the peer is + # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised. + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) if @stateless raise "Stateless mode does not support server-to-client requests." end @@ -463,7 +492,8 @@ module MCP end request_id = generate_request_id - queue = Queue.new + pending_response = PendingResponse.new + wait_timeout = timeout || @server_to_client_request_timeout cancel_hook = nil request = { jsonrpc: "2.0", id: request_id, method: method } @@ -476,7 +506,7 @@ module MCP raise "Session not found: #{session_id}." end - @pending_responses[request_id] = { queue: queue, session_id: session_id } + @pending_responses[request_id] = { queue: pending_response, session_id: session_id } active_stream(session, related_request_id: related_request_id) end @@ -512,7 +542,27 @@ module MCP end end - response = queue.pop + response = pending_response.pop(timeout: wait_timeout) do + # Expiry cancels as well as stops waiting, so a client that answers late does not act on + # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here: + # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests + # never register the session this method looks up. Those revisions ask the sender to "issue + # a cancellation notification for that request and stop waiting", letting either side send one. + # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown + # governs the era that has no such requests to cancel.) Both reference SDKs send this same + # courtesy cancel on timeout. + server_session&.send_peer_cancellation( + nested_request_id: request_id, + related_request_id: related_request_id, + reason: "Timed out after #{wait_timeout} seconds", + ) + + raise RequestTimeoutError.new( + "#{method} request timed out after #{wait_timeout} seconds", + request_id: request_id, + timeout: wait_timeout, + ) + end if response.is_a?(Hash) && response.key?(:error) raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}" diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 0bbec5c..33b2075 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -138,9 +138,9 @@ module MCP # `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 + def list_roots(timeout: nil) if @notification_target.respond_to?(:list_roots) - @notification_target.list_roots(related_request_id: @related_request_id) + @notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout)) else raise NoMethodError, "undefined method 'list_roots' for #{self}" end @@ -160,9 +160,9 @@ module MCP # end # # @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping - def ping + def ping(timeout: nil) if @notification_target.respond_to?(:ping) - @notification_target.ping(related_request_id: @related_request_id) + @notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout)) else raise NoMethodError, "undefined method 'ping' for #{self}" end @@ -245,5 +245,15 @@ module MCP def respond_to_missing?(name, include_private = false) @context.respond_to?(name) || super end + + private + + # An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had + # before per-request timeouts existed. A notification target that predates the keyword + # (a custom object standing in for a session) keeps working until a caller actually asks for a timeout, + # and the transport applies its own default in that case. + def timeout_kwarg(timeout) + timeout.nil? ? {} : { timeout: timeout } + end end end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index cf45797..bf4bb58 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -131,7 +131,7 @@ module MCP # `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) + def list_roots(related_request_id: nil, timeout: nil) @server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) @@ -139,12 +139,12 @@ module MCP raise "Client does not support roots." end - send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id) + send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout) end # Sends a `ping` request scoped to this session. - def ping(related_request_id: nil) - result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id) + def ping(related_request_id: nil, timeout: nil) + result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout) raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash) result @@ -158,12 +158,17 @@ module MCP # @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) + def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) @server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2) 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) + send_to_transport_request( + Methods::SAMPLING_CREATE_MESSAGE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) end # Sends an `elicitation/create` request (form mode) scoped to this session. @@ -171,7 +176,7 @@ module MCP # 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) + def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:elicitation) @@ -180,7 +185,12 @@ module MCP end params = { mode: "form", message: message, requestedSchema: requested_schema } - send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) + send_to_transport_request( + Methods::ELICITATION_CREATE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) end # Sends an `elicitation/create` request (URL mode) scoped to this session. @@ -188,7 +198,7 @@ module MCP # 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) + def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:elicitation, :url) @@ -197,7 +207,12 @@ module MCP end params = { mode: "url", message: message, url: url, elicitationId: elicitation_id } - send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) + send_to_transport_request( + Methods::ELICITATION_CREATE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) end # Sends `notifications/cancelled` to the peer for a nested server-to-client request @@ -296,7 +311,7 @@ module MCP # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing. # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up # so that cancelling the parent also cancels this nested server-to-client request. - def send_to_transport_request(method, params, related_request_id: nil) + def send_to_transport_request(method, params, related_request_id: nil, timeout: nil) parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil kwargs = { @@ -304,6 +319,7 @@ module MCP related_request_id: related_request_id, parent_cancellation: parent_cancellation, server_session: self, + timeout: timeout, }.compact forward_to_transport(@transport.method(:send_request), method, params, kwargs) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index aad90d8..9d17797 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -3041,6 +3041,89 @@ module MCP assert_equal "Hi there", result[:content][:text] end + test "send_request times out when the client never answers" do + # The spec asks implementations to bound every sent request so an unanswered one cannot + # exhaust the sender's resources. + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) + session_id = open_sse_session(transport) + + error = assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + end + + assert_equal 0.05, error.timeout + assert_includes error.message, "sampling/createMessage" + + # Left uncaught in a handler, the originating request is answered with a timeout code rather than + # a generic internal error, so the peer that failed to answer can tell the two apart. + assert_equal(-32001, error.error_code) + ensure + transport.close + end + + test "send_request timeout is overridable per request" do + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 60) + session_id = open_sse_session(transport) + + error = assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("ping", nil, session_id: session_id, timeout: 0.05) + end + + assert_equal 0.05, error.timeout + ensure + transport.close + end + + test "send_request tells the peer it gave up when the timeout fires" do + # These requests only happen on 2025-11-25 and earlier connections, whose spec asks the sender + # to issue a cancellation notification for the request it stops waiting on. + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) + io = StringIO.new + session_id = open_sse_session(transport, io: io) + session = transport.instance_variable_get(:@sessions)[session_id][:server_session] + + assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("ping", nil, session_id: session_id, server_session: session) + end + + events = io.string.lines.grep(/\Adata: /).map { |line| JSON.parse(line.sub("data: ", "")) } + cancellation = events.find { |event| event["method"] == "notifications/cancelled" } + refute_nil cancellation, "expected the peer to be told the request was abandoned" + assert_match(/Timed out after 0.05 seconds/, cancellation.dig("params", "reason")) + ensure + transport.close + end + + test "send_request returns the response when it arrives before the timeout" do + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 30) + io = StringIO.new + session_id = open_sse_session(transport, io: io) + + result_queue = Queue.new + Thread.new do + result_queue.push(transport.send_request("ping", nil, session_id: session_id)) + end + wait_until { io.string.include?("ping") } + + request_id = JSON.parse(io.string.lines.grep(/\Adata: /).first.sub("data: ", ""))["id"] + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: request_id, result: { ok: true } }.to_json, + )) + + assert_equal({ ok: true }, result_queue.pop) + ensure + transport.close + end + + test "server_to_client_request_timeout rejects a non-positive value" do + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: -1) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: nil) } + 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. @@ -6028,6 +6111,36 @@ module MCP @transport.handle_request(init_request)[1]["mcp-session-id"] end + # Polls until the block is truthy; the work being awaited happens on another thread. + def wait_until(timeout: 5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until yield + flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + end + + # Initializes a session on `transport` and attaches a GET SSE stream to it, so + # server-to-client requests have somewhere to go. Returns the session id. + def open_sse_session(transport, io: StringIO.new) + session_id = transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ))[1]["mcp-session-id"] + + get_response = transport.handle_request(create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + )) + get_response[2].call(io) if get_response[2].is_a?(Proc) + wait_until { transport.instance_variable_get(:@sessions).dig(session_id, :get_sse_stream) } + + session_id + end + # Installs a stream that records, on every write, whether `@mutex` was held at that moment. # `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]` # means a blocking write is being performed under the lock. diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 0b26bd0..10089c5 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -162,6 +162,41 @@ module MCP assert_equal({}, result) end + test "ServerContext forwards a per-request timeout to the notification target" do + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil, timeout: 12).returns({}) + notification_target.expects(:list_roots).with(related_request_id: nil, timeout: 34).returns({ roots: [] }) + notification_target.expects(:create_form_elicitation).with( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + related_request_id: nil, + timeout: 56, + ).returns({ action: "accept" }) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) + + server_context.ping(timeout: 12) + server_context.list_roots(timeout: 34) + server_context.create_form_elicitation( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + timeout: 56, + ) + end + + test "ServerContext omits timeout entirely when the caller does not ask for one" do + # A notification target predating the keyword (a custom object standing in for a session) keeps working, + # and the transport applies its own default. + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil).returns({}) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) + + server_context.ping + end + test "ServerContext#ping raises NoMethodError when notification_target does not respond" do notification_target = mock context = mock diff --git a/test/mcp/server_session_request_timeout_test.rb b/test/mcp/server_session_request_timeout_test.rb new file mode 100644 index 0000000..f3ef1e6 --- /dev/null +++ b/test/mcp/server_session_request_timeout_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + # Every server-to-client request accepts a per-request deadline, which the spec asks SDKs to offer + # ("SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis"). + # `ServerSession` only carries the value; the transport is what waits on it. + class ServerSessionRequestTimeoutTest < ActiveSupport::TestCase + # Declares `timeout:`, as the bundled Streamable HTTP transport does. + class TimeoutAwareTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) + @requests << { method: method, timeout: timeout } + {} + end + + def send_response(response); end + def send_notification(method, params = nil, **_kwargs); end + def open; end + def close; end + def handle_request(request); end + end + + # Implements only the abstract `send_request(method, params = nil)` contract, as `StdioTransport` does. + class TimeoutUnawareTransport < TimeoutAwareTransport + def send_request(method, params = nil) + @requests << { method: method, timeout: :not_passed } + {} + end + end + + setup do + @server = Server.new(name: "test_server", version: "1.0.0") + @transport = TimeoutAwareTransport.new(@server) + @session = ServerSession.new(server: @server, transport: @transport, session_id: "session-1") + @session.instance_variable_set( + :@client_capabilities, + { roots: {}, sampling: {}, elicitation: { url: {} } }, + ) + end + + test "every server-to-client request carries its timeout down to the transport" do + @session.ping(related_request_id: "req-1", timeout: 11) + @session.list_roots(related_request_id: "req-1", timeout: 22) + @session.create_sampling_message( + related_request_id: "req-1", + timeout: 33, + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + @session.create_form_elicitation( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + related_request_id: "req-1", + timeout: 44, + ) + @session.create_url_elicitation( + message: "Approve?", + url: "https://example.com/approve", + elicitation_id: "elicit-1", + related_request_id: "req-1", + timeout: 55, + ) + + assert_equal( + [ + { method: "ping", timeout: 11 }, + { method: "roots/list", timeout: 22 }, + { method: "sampling/createMessage", timeout: 33 }, + { method: "elicitation/create", timeout: 44 }, + { method: "elicitation/create", timeout: 55 }, + ], + @transport.requests, + ) + end + + test "an omitted timeout leaves the transport to apply its own default" do + @session.ping(related_request_id: "req-1") + + assert_equal [{ method: "ping", timeout: nil }], @transport.requests + end + + test "a transport that does not declare timeout keeps its existing signature" do + transport = TimeoutUnawareTransport.new(@server) + session = ServerSession.new(server: @server, transport: transport, session_id: "session-1") + + session.ping(related_request_id: "req-1", timeout: 11) + + assert_equal [{ method: "ping", timeout: :not_passed }], transport.requests + end + end +end From bea636c0075b503c31a241191c499128cc92f3f0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 11 Aug 2026 03:21:33 +0900 Subject: [PATCH 200/247] Refuse server-to-client requests in the modern lifecycle per SEP-2575 ## Motivation and Context The modern lifecycle removes server-initiated JSON-RPC requests: a stateless server has no connection to send them on, and SEP-2322 multi round-trip `input_required` results replace what sampling and elicitation covered before. `StdioTransport#send_request` already says so, refusing outright when its session is modern. `StreamableHTTPTransport#send_request` says nothing, so a modern handler that reaches for elicitation or sampling falls through to the session lookup and is told `Session not found: ` against a session id it never chose. Failing is right, the explanation is not: `handle_modern` mints a session per request without ever registering it in `@sessions`, so that lookup cannot succeed for a modern request no matter what the handler does. `send_request` now refuses a modern-era session up front with the message `StdioTransport` uses, which turns an incidental registration detail into a stated rule and leaves the rest of the method a legacy-only path by construction. Callers see the same `RuntimeError` class as before, only with a message naming the actual constraint. ## How Has This Been Tested? A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` sends `elicitation/create` from a session constructed with `era: :modern` and asserts the SEP-2575 message. It sits with the existing guard tests for stateless mode, a missing `session_id`, and an unknown session, which still pass unchanged. `bundle exec rake` is green, and both conformance legs pass their baseline check. ## Breaking Changes None. The call already failed; only the message changes. --- .../server/transports/streamable_http_transport.rb | 10 ++++++++++ .../transports/streamable_http_transport_test.rb | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index e0ac867..48e8de2 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -479,6 +479,16 @@ module MCP # so a client that never answers cannot park the calling thread for good. On expiry the peer is # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised. def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) + # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; + # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached + # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`, + # but that is incidental: without the rule stated here a modern handler that reaches for elicitation + # or sampling is told "Session not found: ", which points at everything except the actual reason. + # `StdioTransport#send_request` refuses the same way. + if server_session&.era == :modern + raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." + end + if @stateless raise "Stateless mode does not support server-to-client requests." end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9d17797..3085356 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -2939,6 +2939,18 @@ module MCP assert_equal("Stateless mode does not support server-to-client requests.", error.message) end + test "send_request refuses to run at all in the modern lifecycle" do + # SEP-2575 removes server-initiated requests, so a modern handler reaching for elicitation or sampling + # is told why rather than being sent down a path that fails on an unrelated session lookup. + modern_session = ServerSession.new(server: @server, transport: @transport, session_id: "modern-1", era: :modern) + + error = assert_raises(RuntimeError) do + @transport.send_request("elicitation/create", { message: "hi" }, session_id: "modern-1", server_session: modern_session) + end + + assert_equal("Server-initiated requests are not available in the modern lifecycle (SEP-2575).", error.message) + end + test "send_request raises error when session_id is not provided" do error = assert_raises(RuntimeError) do @transport.send_request("sampling/createMessage", { "messages" => [] }) From ed802f74945ddc4956d8f2e105323a131130e5db Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 14 Aug 2026 00:55:52 +0900 Subject: [PATCH 201/247] Stop using `ping` as the non-cacheable result example ## Motivation and Context `main` is red on every Ruby version: "modern non-cacheable results carry no cache hints" errors with `undefined method 'key?' for nil`. The test reaches for `response[:result]` on a request that no longer produces a result. Two changes met on `main` without conflicting textually. #499 added the test and picked `ping` as its example of a result that is not cacheable. #511 then made a modern-enveloped request naming a method SEP-2575 removed answer with `-32601` even before the connection era locks, and `ping` is one of those methods, so the response now carries an error rather than a result. Each branch was green against the `main` it was written on; the pairing only failed once both had landed. `ping` was the wrong example regardless. It is absent from the 2026-07-28 schema, so no modern request can name it and receive a result of any shape. `tools/call` is the accurate stand-in: the spec types `CallToolResult` as a plain `Result`, while the ones it types as `CacheableResult` are the discover, list, and read families. The test now calls the tool it already registers, and a comment records why `ping` cannot come back here - the same note #511 left where it replaced `ping` with `prompts/list` in the neighboring `resultType` test. Only the test changes. The behavior both #499 and #511 implement is correct; the example the test chose was not. ## How Has This Been Tested? The test errors on `main` and passes with this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The rest of the suite was searched for the same pattern, a modern-enveloped request naming a method the modern lifecycle removed: the only other occurrence is the assertion #511 added deliberately, which checks that such a request does not lock the era, and which reads no result. ## Breaking Changes None. No library code changes. --- test/mcp/server_test.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index defe93d..480827e 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -4378,7 +4378,10 @@ module MCP test "modern non-cacheable results carry no cache hints" do server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) - response = server.handle(modern_request(Methods::PING, {})) + # `tools/call` is the non-cacheable example here: the spec types `CallToolResult` as a plain `Result`, + # while the results it types as `CacheableResult` are the discover, list, and read families. + # `ping` cannot stand in for it - SEP-2575 removed that method, so a modern request naming it is refused. + response = server.handle(modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} })) refute response[:result].key?(:ttlMs) refute response[:result].key?(:cacheScope) From 2fb9ab8e6a3ca10ee8091ea5b660e7bc1b8833f1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 20:24:27 +0900 Subject: [PATCH 202/247] Drive multi round-trip `input_required` results on the client per SEP-2322 ## Motivation and Context Final step of SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322) for the 2026-07-28 MCP spec release. The client so far only recognized `input_required` results by raising `InputRequiredError`; this adds the resume loop, mirroring the Python SDK's high-level `Client` driver and the TypeScript SDK's `runInputRequiredDriver`. - The driver answers embedded requests with the handlers already registered through `on_elicitation`, `on_sampling`, and the new `on_roots`. There is no second registration path: a server can ask for input two ways, as a real request mid-call and as an entry in an `input_required` result, and one registration covers both. This follows the TypeScript driver, which dispatches "to the client's already-registered handlers (elicitation, sampling, roots - one generic engine, no per-feature API)". - `on_elicitation` and `on_sampling` no longer refuse a transport without `on_server_request`. They record the handler either way and wire it to the transport only when it can carry server-to-client requests. Refusing would have locked stdio clients, and every 2026-07-28 connection, out of the only route the spec leaves: the modern lifecycle forbids server-to-client requests, so an embedded request is how it asks. - `MCP::Client.new` gains `input_required_max_rounds:` (default 10, the TypeScript and Python default). - Once a handler is registered, `call_tool`, `get_prompt`, and `read_resource` resume automatically: each `inputRequests` entry is fulfilled by the matching handler and the ORIGINAL request is re-issued with `inputResponses` under the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg (modern envelope stamping happens in the transports per send, so every leg carries the SEP-2575 triple). A `requestState`-only result (load shedding) retries after an exponential backoff from 50ms to a 250ms cap, matching the Python SDK; every leg counts against the round cap, whose exhaustion raises `InputRequiredError` carrying the last result. - A requested kind without a matching handler falls back to the manual path by raising `InputRequiredError`, and clients with no handlers keep the exact pre-existing behavior. For manual driving, `call_tool` / `get_prompt` / `read_resource` gain `input_responses:` and `request_state:` keyword arguments that ride at the params top level. Non-MRTR methods such as `tools/list` keep raising unconditionally. The conformance client rides the same change: it derives its lifecycle from the wire version the harness names per run (`MCP_CONFORMANCE_PROTOCOL_VERSION`), connecting with `mode: :modern` for the 2026 draft and keeping the pinned legacy handshake through 2025-11-25, retrying a modern connect once when the server rejects the first request with `-32022` naming its supported versions. New scenario branches drive `sep-2322-client-request-state` through this driver (an accepting `on_elicitation` handler plus the four `test_mrtr_*` tools), `request-metadata`, both SEP-2243 header scenarios, and `json-schema-ref-no-deref`. The expected-failures baseline drops the six scenarios this stack resolves, leaving only the not-scored extensions and the post-anchor `json-schema-2020-12-preservation`. Verified end-to-end over stdio: a modern connection (`connect(mode: :modern)` with declared form-elicitation capability) against a server whose tool returns `InputRequiredResult` under `RequestStateSecurity` sealing completes in two legs with the handler supplying the answer, and a handler-less client receives `InputRequiredError` with the sealed opaque state. Part of #382. ## How Has This Been Tested? New tests in `test/mcp/client_test.rb` (Mocha sequenced transports) cover: the two-leg happy path asserting preserved original params, key-matched `inputResponses`, byte-exact `requestState` echo, and a fresh id per leg; the exponential backoff sequence for `requestState`-only legs (stubbed `sleep`); round-cap exhaustion raising `InputRequiredError` with the last state; the unhandled-kind fallback; the manual `input_responses:`/`request_state:` keyword arguments; and the `prompts/get` driver path. The existing SEP-2322 recognition tests pin the no-handler behavior unchanged. Three tests cover the shared-registration design specifically: the driver resolving an embedded request on a transport that has no `on_server_request` at all, an `on_roots` handler answering an embedded `roots/list`, and `on_elicitation` and `on_sampling` accepting a registration on such a transport rather than raising, which replaces the two tests that pinned the old refusal. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes, plus the stdio end-to-end script described above. With the separate SEP-2243 mirroring change applied alongside, every scored scenario of the frozen 2026-07-28 client leg passes (`sep-2322-client-request-state` 5/5, `request-metadata` 5/5 with no version-retry warning, `http-custom-headers` 18/18, `http-invalid-tool-headers` 11/11), and the `--requirements` client legs at both revisions report failures only in not-scored scenarios. The full conformance task passes its baseline check on both legs. ## Breaking Changes `on_elicitation` and `on_sampling` no longer raise `ArgumentError` on a transport without `on_server_request`; they register the handler for the `input_required` route instead. Code that relied on the refusal to detect an unsuitable transport needs another check. Everything else is additive: the new keyword arguments default to `nil`, and with no handler registered every code path is identical to before. --- README.md | 8 +- conformance/client.rb | 65 +++++++- conformance/expected_failures.yml | 13 -- lib/mcp/client.rb | 203 ++++++++++++++++++++---- test/mcp/client_test.rb | 251 ++++++++++++++++++++++++++---- 5 files changed, 463 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index a6d001f..2fd4454 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,13 @@ It implements the Model Context Protocol specification, handling model context r client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers. Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as - the handler wrote it and protecting it is the handler author's responsibility + the handler wrote it and protecting it is the handler author's responsibility. On the client, register handlers with + `on_elicitation` / `on_sampling` / `on_roots` - the same registrations that answer a real server-to-client request - and + declare the matching capabilities on `connect` (a server embeds only the request kinds the client declared); + `call_tool` / `get_prompt` / `read_resource` then resume `input_required` results automatically: each embedded request is fulfilled by + the matching handler and the original request is re-issued with `inputResponses` plus the echoed `requestState` + (with exponential backoff for `requestState`-only load-shedding legs). Without a matching handler they raise `MCP::Client::InputRequiredError`, + and the `input_responses:` / `request_state:` keyword arguments support manual driving - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/conformance/client.rb b/conformance/client.rb index 23403d3..c04d272 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -162,6 +162,13 @@ end oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) + +# SEP-2322: the MRTR scenario exercises the automatic driver; this elicitation handler accepts +# every embedded confirmation request, and the driver echoes `requestState` byte-exactly. +# The same registration answers a real `elicitation/create` request, so nothing here is MRTR-specific. +if scenario == "sep-2322-client-request-state" + client.on_elicitation { |_params| { action: "accept", content: { confirmed: true } } } +end capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {} # The conformance harness asserts on the observed protocol exchange, not on the client's exit status, # and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios @@ -171,13 +178,28 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation # free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace # and a failing exit status. begin - # The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle: - # the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect. - client.connect( - client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, - capabilities: capabilities, - mode: :legacy, - ) + # The harness names the wire version per scenario run: the 2026 draft selects the stateless modern lifecycle, + # and every dated version through 2025-11-25 keeps the legacy `initialize` handshake + # (pinned rather than `:auto`, whose `server/discover` probe the legacy scenario servers do not expect). + mode = ENV["MCP_CONFORMANCE_PROTOCOL_VERSION"] == "2026-07-28" ? :modern : :legacy + connect = lambda do + client.connect( + client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, + capabilities: capabilities, + mode: mode, + ) + end + + begin + connect.call + rescue MCP::Client::RequestHandlerError + # SEP-2575 version negotiation: a server may reject the first request with `-32022` naming + # its supported versions. Retry once so the harness observes a follow-up request carrying + # a mutually supported version (this client already speaks the latest draft). + raise unless mode == :modern + + connect.call + end case scenario when "initialize" @@ -190,6 +212,35 @@ begin focal = tools.find { |t| t.name == "json_schema_2020_12_tool" } abort("Tool json_schema_2020_12_tool not found") unless focal client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema }) + when "request-metadata" + # SEP-2575: the checks read the envelope and headers of the requests the modern connect + # and this listing already produce. + client.tools + when "sep-2322-client-request-state" + # SEP-2322: the driver resumes `test_mrtr_echo_state` automatically (fresh JSON-RPC id, + # byte-exact `requestState` echo); `test_mrtr_unrelated` must carry neither retry field; + # `test_mrtr_no_state` retries without inventing a state; `test_mrtr_no_result_type` + # has no `resultType` and therefore must be treated as complete, with no retry. + client.tools + client.call_tool(name: "test_mrtr_echo_state", arguments: {}) + client.call_tool(name: "test_mrtr_unrelated", arguments: {}) + client.call_tool(name: "test_mrtr_no_state", arguments: {}) + client.call_tool(name: "test_mrtr_no_result_type", arguments: {}) + when "http-custom-headers" + # SEP-2243: the harness names the exact tool calls (with encoding edge-case values) via context; + # listing first teaches the transport the `x-mcp-header` declarations it mirrors. + client.tools + (conformance_context["toolCalls"] || []).each do |tool_call| + client.call_tool(name: tool_call["name"], arguments: tool_call["arguments"]) + end + when "http-invalid-tool-headers" + # SEP-2243: the listing carries one valid tool among invalid `x-mcp-header` definitions; + # calling only the valid one shows the invalid definitions did not block it. + client.tools + client.call_tool(name: "valid_tool", arguments: { "message" => "hello" }) + when "json-schema-ref-no-deref" + # SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing. + client.tools when "tools_call" tools = client.tools add_numbers = tools.find { |t| t.name == "add_numbers" } diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 5c1dbec..9325842 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,19 +1,6 @@ server: [] client: - # Pending client-side support for the 2026-07-28 stateless lifecycle: the conformance client - # still connects through the legacy handshake and has no SEP-2322 MRTR driver yet. - - request-metadata - - sep-2322-client-request-state - # SEP-2243 client-side custom-header mirroring and invalid-tool header gating - # are not implemented in `MCP::Client::HTTP` yet. - - http-custom-headers - - http-invalid-tool-headers - # Client-side JSON Schema handling: `$ref` values must reach the caller unresolved, - # and the conformance client does not run the scenario over the modern lifecycle yet. - - json-schema-ref-no-deref # Unimplemented authorization extensions. - auth/dpop - auth/dpop-nonce - auth/wif-jwt-bearer - # Fails one check added to the suite after the 0.2.0-alpha.10 requirements anchor. - - auth/authorization-server-migration diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 4db0197..e1de318 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -57,8 +57,9 @@ module MCP # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue # the original request with `inputResponses` plus the echoed opaque `requestState`. - # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests` - # and respond manually. + # With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs + # automatically; this error surfaces when no matching handler exists (manual driving via + # `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 class InputRequiredError < StandardError attr_reader :input_requests, :request_state, :result @@ -93,16 +94,36 @@ module MCP keyword_init: true, ) + # Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and + # Python SDK defaults. Every leg counts, including `requestState`-only retries. + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10 + + # Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to + # a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms). + STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05 + STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25 + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. # The transport should be a duck type that responds to `send_request`. See the README for more details. + # @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds. + # + # Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`, + # `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers + # (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving, + # exactly as before. # # @example # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") # client = MCP::Client.new(transport: transport) - def initialize(transport:) + def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS) @transport = transport + # Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways + # the server can ask for input: a real server-to-client request, and an embedded request inside + # a SEP-2322 `input_required` result. + @input_required_handlers = {} + @input_required_max_rounds = input_required_max_rounds end # The user may want to access additional transport-specific methods/attributes @@ -431,7 +452,10 @@ module MCP # @note # The exact requirements for `arguments` are determined by the transport layer in use. # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil) + # @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`, + # keyed identically (manual retry legs). + # @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly. + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name @@ -442,8 +466,10 @@ module MCP meta_entries[:progressToken] = progress_token end params[:_meta] = meta_entries unless meta_entries.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state - request(method: "tools/call", params: params, cancellation: cancellation) + drive_input_required(method: "tools/call", params: params, cancellation: cancellation) end # Reads a resource from the server by URI and returns the contents. @@ -453,8 +479,13 @@ module MCP # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:, meta: nil, cancellation: nil) - response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation) + def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { uri: uri } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation) response.dig("result", "contents") || [] end @@ -466,8 +497,13 @@ module MCP # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] A hash containing the prompt details. - def get_prompt(name:, meta: nil, cancellation: nil) - response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation) + def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { name: name } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation) response.fetch("result", {}) end @@ -496,8 +532,11 @@ module MCP # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash: # `{ action: "accept" | "decline" | "cancel", content: { ... } }`. # - # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); - # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them. + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it + # forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322, + # a server MUST NOT embed input requests of a kind the client has not declared. # # @example Accept with schema defaults applied (SEP-1034) # client.on_elicitation do |params| @@ -508,11 +547,7 @@ module MCP # end # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation def on_elicitation(&handler) - unless transport.respond_to?(:on_server_request) - raise ArgumentError, "The transport does not support server-to-client requests" - end - - transport.on_server_request(Methods::ELICITATION_CREATE, &handler) + register_input_handler(Methods::ELICITATION_CREATE, &handler) end # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight. @@ -523,8 +558,11 @@ module MCP # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`. # - # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to - # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them. + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to + # `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of + # a kind the client has not declared. # # @example Forward the request to an LLM and return its completion # @@ -546,11 +584,23 @@ module MCP # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window; # new servers should call LLM provider APIs directly. def on_sampling(&handler) - unless transport.respond_to?(:on_server_request) - raise ArgumentError, "The transport does not support server-to-client requests" - end + register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler) + end - transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler) + # Registers a handler for `roots/list`, answering both a server-to-client request on transports that + # support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler + # receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash: + # `{ roots: [{ uri: "file:///project", name: "Project" }] }`. + # + # @example + # client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } } + # + # @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Register this handler only to interoperate with servers that still ask for roots. + # + # https://modelcontextprotocol.io/specification/2025-11-25/client/roots + def on_roots(&handler) + register_input_handler(Methods::ROOTS_LIST, &handler) end # Sends a `ping` request to the server to verify the connection is alive. @@ -576,6 +626,19 @@ module MCP private + # Records a handler for one of the three kinds of input a server can ask this client for, and wires it to + # the transport when the transport can carry server-to-client requests. One registration serves both routes: + # the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322 + # `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client + # requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio, + # and every modern-lifecycle connection); only the wire route is skipped there. + def register_input_handler(method, &handler) + @input_required_handlers[method] = handler + transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request) + + handler + end + # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. @@ -655,7 +718,7 @@ module MCP # without mutating the caller's hashes. Per SEP-414, `_meta` carries # request-specific metadata such as W3C trace context (`traceparent`, # `tracestate`, `baggage`); see {MCP::TraceContext}. - def request(method:, params: nil, meta: nil, cancellation: nil) + def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true) params = (params || {}).merge(_meta: meta) if meta && !meta.empty? request_body = { @@ -677,20 +740,104 @@ module MCP raise ServerError.new(error["message"], code: error["code"], data: error["data"]) end - raise_on_input_required(response) + raise_on_input_required(response) if raise_on_input_required response end + # Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`. + # With no configured handlers this degrades to the plain request (an `input_required` result raises + # `InputRequiredError` for manual driving). Otherwise each `inputRequests` entry is fulfilled by + # the matching handler and the ORIGINAL request is re-issued with `inputResponses` under + # the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg. + # A `requestState`-only result (load shedding) retries after an exponential backoff. + # Every leg counts against `input_required_max_rounds`. + def drive_input_required(method:, params:, cancellation:) + response = request( + method: method, + params: params, + cancellation: cancellation, + raise_on_input_required: @input_required_handlers.empty?, + ) + return response unless input_required?(response) + + original_params = params.dup + original_params.delete(:inputResponses) + original_params.delete(:requestState) + rounds = 0 + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + + loop do + result = response["result"] + rounds += 1 + if rounds > @input_required_max_rounds + raise InputRequiredError.new( + "Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).", + input_requests: result["inputRequests"] || {}, + request_state: result["requestState"], + result: result, + ) + end + + input_requests = result["inputRequests"] || {} + responses = nil + if input_requests.empty? + sleep(backoff) + backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min + else + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + responses = fulfill_input_requests(input_requests, result) + end + + retry_params = original_params.dup + retry_params[:inputResponses] = responses if responses + retry_params[:requestState] = result["requestState"] if result["requestState"] + + response = request( + method: method, + params: retry_params, + cancellation: cancellation, + raise_on_input_required: false, + ) + return response unless input_required?(response) + end + end + + # Dispatches every embedded request to its configured handler and collects + # the answers under the same keys. A kind without a handler falls back to + # the manual path by raising `InputRequiredError` with the full result. + def fulfill_input_requests(input_requests, result) + input_requests.each_with_object({}) do |(key, entry), responses| + entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil + handler = @input_required_handlers[entry_method] + unless handler + raise InputRequiredError.new( + "Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \ + "is configured; inspect `input_requests` to respond manually (SEP-2322).", + input_requests: input_requests, + request_state: result["requestState"], + result: result, + ) + end + + responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil) + end + end + + def input_required?(response) + result = response.is_a?(Hash) ? response["result"] : nil + result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + end + # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result. # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them. def raise_on_input_required(response) - result = response.is_a?(Hash) ? response["result"] : nil - return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + return unless input_required?(response) + result = response["result"] raise InputRequiredError.new( - "Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \ - "Inspect `input_requests` to respond manually.", + "Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \ + "`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.", input_requests: result["inputRequests"] || {}, request_state: result["requestState"], result: result, diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index b97f9f6..d944698 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -55,15 +55,14 @@ module MCP Client.new(transport: transport).on_elicitation(&handler) end - def test_on_elicitation_raises_when_transport_does_not_support_server_requests + def test_on_elicitation_is_accepted_when_transport_does_not_support_server_requests + # The handler still answers embedded `elicitation/create` requests of SEP-2322 `input_required` results, + # which need no server-to-client route. Refusing to register it would lock stdio clients, + # and every modern-lifecycle connection, out of the only way the spec leaves for asking. transport = mock transport.stubs(:respond_to?).with(:on_server_request).returns(false) - error = assert_raises(ArgumentError) do - Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } - end - - assert_includes(error.message, "does not support server-to-client requests") + Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } end def test_on_sampling_registers_handler_on_transport @@ -74,15 +73,19 @@ module MCP Client.new(transport: transport).on_sampling(&handler) end - def test_on_sampling_raises_when_transport_does_not_support_server_requests + def test_on_sampling_is_accepted_when_transport_does_not_support_server_requests transport = mock transport.stubs(:respond_to?).with(:on_server_request).returns(false) - error = assert_raises(ArgumentError) do - Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } - end + Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } + end - assert_includes(error.message, "does not support server-to-client requests") + def test_on_roots_registers_handler_on_transport + transport = mock + handler = proc { { roots: [{ uri: "file:///project", name: "Project" }] } } + transport.expects(:on_server_request).with("roots/list") + + Client.new(transport: transport).on_roots(&handler) end # A transport that declares `mode:` on `connect`, recording what it received. @@ -380,16 +383,20 @@ module MCP # constraints MUST be excluded from `tools/list` results, with a warning naming the tool. transport = mock transport.stubs(:modern?).returns(true) - mock_response = { "result" => { "tools" => [ - { "name" => "valid_tool", "inputSchema" => { "type" => "object" } }, - { - "name" => "invalid_tool", - "inputSchema" => { - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, - }, + mock_response = { + "result" => { + "tools" => [ + { "name" => "valid_tool", "inputSchema" => { "type" => "object" } }, + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ], }, - ] } } + } transport.expects(:send_request).returns(mock_response).once client = Client.new(transport: transport) @@ -408,15 +415,19 @@ module MCP def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection transport = mock transport.stubs(:modern?).returns(false) - mock_response = { "result" => { "tools" => [ - { - "name" => "invalid_tool", - "inputSchema" => { - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, - }, + mock_response = { + "result" => { + "tools" => [ + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ], }, - ] } } + } transport.expects(:send_request).returns(mock_response).once client = Client.new(transport: transport) @@ -542,6 +553,190 @@ module MCP assert_raises(Client::InputRequiredError) { client.list_tools } end + def test_call_tool_drives_the_input_required_loop_with_a_configured_handler + transport = mock + sent_requests = [] + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { + "region" => { "method" => "elicitation/create", "params" => { "message" => "Which region?" } }, + }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "type" => "text", "text" => "done" }] } } + transport.expects(:send_request).twice.with do |args| + sent_requests << args[:request] + true + end.returns(input_required_response, final_response) + + handled_params = nil + client = Client.new(transport: transport) + client.on_elicitation do |params| + handled_params = params + { action: "accept", content: { value: "us-east-1" } } + end + + response = client.call_tool(name: "my_tool", arguments: { city: "Tokyo" }) + + assert_equal("done", response.dig("result", "content", 0, "text")) + assert_equal({ "message" => "Which region?" }, handled_params) + + first_leg, retry_leg = sent_requests + # The ORIGINAL params are preserved; the answers ride under the same keys with + # the byte-exact `requestState` echo, on a fresh JSON-RPC id. + assert_equal("my_tool", retry_leg.dig(:params, :name)) + assert_equal({ city: "Tokyo" }, retry_leg.dig(:params, :arguments)) + assert_equal( + { "region" => { action: "accept", content: { value: "us-east-1" } } }, + retry_leg.dig(:params, :inputResponses), + ) + assert_equal("state-1", retry_leg.dig(:params, :requestState)) + refute_equal(first_leg[:id], retry_leg[:id]) + end + + def test_input_required_loop_backs_off_on_request_state_only_legs + transport = mock + state_only_response = { + "result" => { "resultType" => "input_required", "requestState" => "busy-state" }, + } + final_response = { "result" => { "content" => [] } } + transport.stubs(:send_request).returns(state_only_response, state_only_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| {} } + slept = [] + client.stubs(:sleep).with { |seconds| slept << seconds } + + response = client.call_tool(name: "my_tool") + + assert_equal(final_response, response) + + # Exponential from 50ms, matching the Python SDK. + assert_equal([0.05, 0.1], slept) + end + + def test_input_required_loop_raises_after_max_rounds + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => {} } }, + "requestState" => "state-1", + }, + } + transport.stubs(:send_request).returns(input_required_response) + + client = Client.new(transport: transport, input_required_max_rounds: 2) + client.on_elicitation { |_params| { action: "decline" } } + + error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } + + assert_includes(error.message, "after 2 rounds") + assert_equal("state-1", error.request_state) + end + + def test_input_required_loop_falls_back_to_the_error_for_unhandled_request_kinds + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "llm" => { "method" => "sampling/createMessage", "params" => {} } }, + "requestState" => "state-1", + }, + } + transport.expects(:send_request).returns(input_required_response).once + + client = Client.new(transport: transport) + client.on_elicitation { |_params| {} } + + error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } + + assert_includes(error.message, "sampling/createMessage") + assert_equal("state-1", error.request_state) + end + + def test_input_required_loop_runs_on_a_transport_without_server_request_support + # The modern lifecycle forbids server-to-client requests, so an `input_required` result is the only + # way a server can ask. A transport with no `on_server_request` must still drive the loop. + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "Region?" } } }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "text" => "done" }] } } + transport.expects(:send_request).twice.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| { action: "accept", content: { value: "us-east-1" } } } + + response = client.call_tool(name: "my_tool") + + assert_equal("done", response.dig("result", "content", 0, "text")) + end + + def test_input_required_loop_uses_a_roots_handler_registered_with_on_roots + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "r" => { "method" => "roots/list" } }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "text" => "done" }] } } + transport.expects(:on_server_request).with("roots/list") + transport.expects(:send_request).twice.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_roots { |_params| { roots: [{ uri: "file:///project", name: "Project" }] } } + + response = client.call_tool(name: "my_tool") + + assert_equal("done", response.dig("result", "content", 0, "text")) + end + + def test_call_tool_sends_manual_retry_fields + transport = mock + transport.expects(:send_request).with do |args| + params = args[:request][:params] + params[:inputResponses] == { "region" => { action: "accept" } } && params[:requestState] == "state-1" && params[:name] == "my_tool" + end.returns({ "result" => { "content" => [] } }).once + + client = Client.new(transport: transport) + client.call_tool(name: "my_tool", input_responses: { "region" => { action: "accept" } }, request_state: "state-1") + end + + def test_get_prompt_drives_the_input_required_loop + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "?" } } }, + "requestState" => "prompt-state", + }, + } + final_response = { "result" => { "messages" => [] } } + sent_requests = [] + transport.expects(:send_request).twice.with do |args| + sent_requests << args[:request] + true + end.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| { action: "accept", content: {} } } + + result = client.get_prompt(name: "my_prompt") + + assert_equal({ "messages" => [] }, result) + assert_equal("prompt-state", sent_requests[1].dig(:params, :requestState)) + end + def test_call_tool_raises_when_no_name_or_tool client = Client.new(transport: mock) From b159314baab8b2075acece4683150e67d995e2cd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 5 Jul 2026 02:31:20 +0900 Subject: [PATCH 203/247] Fulfill `input_required` results on the legacy wire per SEP-2322 ## Motivation and Context SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests, but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType` as a final result, so the server rejected `input_required` on legacy requests with an internal error. That forced authors to keep two handler styles, one per era. This adds the dual-era authoring shim both reference implementations converged on: the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381) and the Python SDK's version-negotiating resolver (python-sdk#2986). When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on the legacy wire, the server fulfills it in place of the client's driver: - Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like the standalone request), stamped with the originating request id so the association requirement of SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream. One exception to the verbatim forwarding: a URL-mode elicitation leg gains a synthesized `elicitationId` when the handler supplied none, since the 2025-11-25 wire requires the field and the 2026-07-28 in-band shape dropped it (correlation rides `requestState` there) - matching the TypeScript shim. - The answers are collected under the same keys and the handler re-runs with `inputResponses` and the raw `requestState` merged into the original params - the same deterministic replay contract the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors. Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`. - `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is wire hardening against a tampering client and does not apply to the in-process replay. - The embedded-capability gate still runs each round against the session's declared client capabilities; a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets an internal error naming the missing capabilities. - Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default; exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form that cannot make progress in-process. The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores the strict rejection. Requests without a session (no notification path back to a client) and all modern requests are untouched: the modern wire keeps returning the `input_required` result for the client driver. Resolves #382. ## How Has This Been Tested? `bundle exec rake` passes: 1691 test runs / 4381 assertions with zero failures and RuboCop clean. The conformance baseline check passes on both legs (the frozen 2025-11-25 wire is where the shim lives), and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched. New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through a real elicitation with the SEP-2260 association asserted on the transport, replay exposing the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`, `prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing before client contact, the URL-mode leg gaining a synthesized `elicitationId` (and a handler-supplied one riding unchanged), a client answering a fulfilment leg with an error failing the original request, the opt-out, session-less rejection, and modern requests staying untouched. The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged. ## Breaking Changes Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied on the internal-error rejection: those now drive a real fulfilment round trip by default. No such handler could have worked end to end before (the request always failed), so this is the intended upgrade; `input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged. --- README.md | 8 + lib/mcp/server.rb | 115 +++++- lib/mcp/server_session.rb | 9 + .../server_input_required_legacy_shim_test.rb | 337 ++++++++++++++++++ 4 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 test/mcp/server_input_required_legacy_shim_test.rb diff --git a/README.md b/README.md index 2fd4454..20b3143 100644 --- a/README.md +++ b/README.md @@ -2986,6 +2986,14 @@ SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 the modern `_meta` envelope (and on `server/discover` results), while results that already carry a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. Legacy results stay unstamped, and clients treat an absent `resultType` as `"complete"` per the spec. +#### Dual-era authoring (legacy fulfilment shim) + +Handlers written in the 2026 style serve pre-2026 clients too: when a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on the legacy wire, +the server fulfills it in place of the client's driver. Each `inputRequests` entry is sent as the equivalent real server-to-client request +(`elicitation/create`, `sampling/createMessage`, `roots/list`), associated with the originating request per SEP-2260; the answers are collected under the same keys, +and the handler re-runs with `server_context.input_responses` populated and the raw `requestState` echoed, the same deterministic replay contract the modern client driver follows. +The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; `MCP::Server.new(input_required_legacy_shim: false)` restores the strict rejection of `input_required` results on legacy requests. + ## Conformance Testing The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index f880c6a..5fa024b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "securerandom" + require_relative "../json_rpc_handler" require_relative "cancellation" require_relative "cancelled_error" @@ -201,6 +203,7 @@ module MCP ttl_ms: nil, cache_scope: nil, request_state_security: nil, + input_required_legacy_shim: true, transport: nil ) @description = description @@ -221,6 +224,12 @@ module MCP self.ttl_ms = ttl_ms self.cache_scope = cache_scope @request_state_security = request_state_security + + # Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled + # through real server-to-client requests and the handler re-runs, so handlers written + # in the 2026 style serve both eras. `false` restores the strict rejection of `input_required` + # on legacy requests. Matches the TypeScript SDK's default-on legacy shim. + @input_required_legacy_shim = input_required_legacy_shim @configuration = MCP.configuration.merge(configuration) @client = nil @client_protocol_version = nil @@ -679,7 +688,18 @@ module MCP # Runs after the cancellation check so a cancelled request stays suppressed # instead of turning into a gate error response. if result.is_a?(InputRequiredResult) - result = serialize_input_required_result(result, envelope: envelope, request: params, method: method) + result = if envelope.nil? && @input_required_legacy_shim && session + run_legacy_input_required_shim( + result, + method: method, + params: params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + else + serialize_input_required_result(result, envelope: envelope, request: params, method: method) + end end # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns; @@ -817,6 +837,99 @@ module MCP # `inputResponses`/`requestState` (SEP-2322). MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze + # Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`). + LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8 + + # Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result, + # which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver. + # Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with + # the originating request per SEP-2260), the answers are collected under the same keys, and the handler + # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay + # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value + # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply. + def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:) + rounds = 0 + + loop do + missing = result.missing_client_capabilities(session.client_capabilities) + unless missing.empty? + # The explicit `error_code` keeps the descriptive message in the JSON-RPC error response + # (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire + # gets a plain internal error. + raise RequestHandlerError.new( + "input_required requires client capabilities the client did not declare: #{missing.to_json}", + params, + error_type: :internal_error, + error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + ) + end + + responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected| + collected[key] = session.fulfill_input_request( + entry[:method], + legacy_leg_params(entry), + related_request_id: related_request_id, + ) + end + + retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) } + retry_params[:inputResponses] = responses unless responses.empty? + retry_params[:requestState] = result.request_state if result.request_state + + result = redispatch_mrtr_method( + method, + retry_params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + return result unless result.is_a?(InputRequiredResult) + + rounds += 1 + next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS + + raise RequestHandlerError.new( + "Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)", + params, + error_type: :internal_error, + error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + ) + end + end + + # The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with + # one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests, + # a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there), + # so a missing one is synthesized for the leg, matching the TypeScript SDK's shim. + def legacy_leg_params(entry) + params = entry[:params] + return params unless entry[:method] == Methods::ELICITATION_CREATE + return params if params.nil? || (params[:mode] || params["mode"]) != "url" + return params if params.key?(:elicitationId) || params.key?("elicitationId") + + params.merge(elicitationId: SecureRandom.uuid) + end + + # Re-runs the handler of one of the three MRTR-capable methods for + # the legacy shim. Legacy wire, so no envelope is threaded. + def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:) + case method + when Methods::TOOLS_CALL + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + when Methods::PROMPTS_GET + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + when Methods::RESOURCES_READ + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) + else + raise RequestHandlerError.new( + "input_required results are only supported for #{MRTR_METHODS.join(", ")}", + params, + error_type: :internal_error, + ) + end + end + # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch, # so handlers always read the state they wrote. A tampered, expired, or cross-request token is # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior. diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index bf4bb58..3149736 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -215,6 +215,15 @@ module MCP ) end + # Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire, + # for the server's dual-era fulfilment shim. + # The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent + # standalone request - and stays associated with the originating client request per SEP-2260. + # Returns the client's result. + def fulfill_input_request(method, params, related_request_id:) + send_to_transport_request(method, params, related_request_id: related_request_id) + end + # Sends `notifications/cancelled` to the peer for a nested server-to-client request # that was started inside a now-cancelled parent request. `related_request_id` # is the parent request id so the notification is routed to the same stream diff --git a/test/mcp/server_input_required_legacy_shim_test.rb b/test/mcp/server_input_required_legacy_shim_test.rb new file mode 100644 index 0000000..ce0f4b4 --- /dev/null +++ b/test/mcp/server_input_required_legacy_shim_test.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + # The SEP-2322 dual-era authoring shim: a handler returning an `InputRequiredResult` + # on the legacy wire is fulfilled through real server-to-client requests and re-run, + # so handlers written in the 2026 style serve pre-2026 clients too. + class ServerInputRequiredLegacyShimTest < ActiveSupport::TestCase + # Answers every server-to-client request like a cooperative client and records what was sent. + class AnsweringTransport + attr_reader :sent + + def initialize(answers = {}) + @answers = answers + @sent = [] + end + + def send_request(method, params = nil, related_request_id: nil) + @sent << { method: method, params: params, related_request_id: related_request_id } + @answers.fetch(method) do + case method + when Methods::ELICITATION_CREATE + { action: "accept", content: { name: "Koichi" } } + when Methods::ROOTS_LIST + { roots: [{ uri: "file:///workspace", name: "workspace" }] } + when Methods::SAMPLING_CREATE_MESSAGE + { role: "assistant", content: { type: "text", text: "sampled" }, model: "test-model" } + end + end + end + + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + true + end + end + + # Raises like the bundled transports when the client answers a server-to-client request + # with a JSON-RPC error. + class RejectingTransport + def send_request(method, params = nil, related_request_id: nil) + raise StandardError, "Client returned an error for #{method} request (code: -1): User rejected" + end + + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + true + end + end + + setup do + @greeting_tool = Tool.define(name: "greeter") do |server_context:| + answer = server_context.input_response("who") + if answer + Tool::Response.new([{ type: "text", text: "Hello, #{answer.dig(:content, :name)}!" }]) + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + request_state: "round-1", + ) + end + end + end + + test "a legacy tools/call is fulfilled through a real elicitation and completes" do + transport = AnsweringTransport.new + session = legacy_session(tools: [@greeting_tool], transport: transport) + + response = session.handle(tool_call_request) + + assert_equal "Hello, Koichi!", response.dig(:result, :content, 0, :text) + refute response[:result].key?(:resultType) + + sent = transport.sent.first + assert_equal Methods::ELICITATION_CREATE, sent[:method] + assert_equal({ message: "Who?" }, sent[:params]) + + # SEP-2260: the fulfilment request stays associated with the originating request. + assert_equal 1, sent[:related_request_id] + end + + test "the handler re-runs with the collected responses and the raw requestState" do + seen = [] + tool = Tool.define(name: "stateful") do |server_context:| + seen << { responses: server_context.input_responses, state: server_context.request_state } + if server_context.request_state == "opaque-state" + Tool::Response.new([{ type: "text", text: "resumed" }]) + else + Server::InputRequiredResult.new( + input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: "opaque-state", + ) + end + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "stateful")) + + assert_equal "resumed", response.dig(:result, :content, 0, :text) + assert_equal 2, seen.size + assert_nil seen.first[:responses] + assert_equal "opaque-state", seen.last[:state] + assert_equal({ action: "accept", content: { name: "Koichi" } }, seen.last[:responses]["k"]) + end + + test "the shim bypasses requestState sealing" do + seen_state = nil + tool = Tool.define(name: "sealed") do |server_context:| + if server_context.request_state + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new( + input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: "plain-state", + ) + end + end + security = Server::RequestStateSecurity.new(key: "k" * 32) + session = legacy_session(tools: [tool], transport: AnsweringTransport.new, request_state_security: security) + + response = session.handle(tool_call_request(name: "sealed")) + + assert_equal "done", response.dig(:result, :content, 0, :text) + + # In-process replay round-trips the handler's own value; sealing is wire hardening. + assert_equal "plain-state", seen_state + end + + test "prompts/get and resources/read are shimmed too" do + prompt = Prompt.define(name: "greeting_prompt") do |_args, server_context:| + if server_context.input_response("who") + Prompt::Result.new(messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))]) + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + ) + end + end + server = Server.new(name: "shim_test", prompts: [prompt], resources: []) + server.resources_read_handler do |params| + # `server_context` is unavailable in this handler shape, so park once via the params-visible retry fields instead. + if params[:inputResponses] + [{ uri: params[:uri], mimeType: "text/plain", text: "read" }] + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + ) + end + end + transport = AnsweringTransport.new + session = ServerSession.new(server: server, transport: transport, session_id: "legacy") + session.store_client_info(client: { name: "legacy" }, capabilities: { elicitation: { form: {} } }) + + prompt_response = session.handle( + { jsonrpc: "2.0", id: 10, method: Methods::PROMPTS_GET, params: { name: "greeting_prompt" } }, + ) + assert_equal "hi", prompt_response.dig(:result, :messages, 0, :content, :text) + + read_response = session.handle( + { jsonrpc: "2.0", id: 11, method: Methods::RESOURCES_READ, params: { uri: "file:///a.txt" } }, + ) + assert_equal "read", read_response.dig(:result, :contents, 0, :text) + end + + test "multiple rounds collect each round's answers" do + rounds = [] + tool = Tool.define(name: "two_rounds") do |server_context:| + rounds << server_context.input_responses&.keys + if server_context.input_response("second") + Tool::Response.new([{ type: "text", text: "done" }]) + elsif server_context.input_response("first") + Server::InputRequiredResult.new( + input_requests: { "second" => { method: Methods::ELICITATION_CREATE, params: { message: "2?" } } }, + request_state: "after-first", + ) + else + Server::InputRequiredResult.new( + input_requests: { "first" => { method: Methods::ELICITATION_CREATE, params: { message: "1?" } } }, + ) + end + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "two_rounds")) + + assert_equal "done", response.dig(:result, :content, 0, :text) + assert_equal [nil, ["first"], ["second"]], rounds + end + + test "a url-mode elicitation leg gains the elicitationId the legacy wire requires" do + # The 2026-07-28 in-band shape has no `elicitationId` (correlation rides `requestState`), + # but the 2025-11-25 wire requires the field on URL-mode elicitation requests. + transport = AnsweringTransport.new + tool = Tool.define(name: "url_asker") do |server_context:| + if server_context.input_response("auth") + Tool::Response.new([{ type: "text", text: "authed" }]) + else + Server::InputRequiredResult.new( + input_requests: { + "auth" => { + method: Methods::ELICITATION_CREATE, + params: { mode: "url", url: "https://example.com/auth", message: "Sign in" }, + }, + }, + ) + end + end + session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) + + response = session.handle(tool_call_request(name: "url_asker")) + + assert_equal "authed", response.dig(:result, :content, 0, :text) + sent = transport.sent.first + assert_equal "https://example.com/auth", sent[:params][:url] + refute_nil sent[:params][:elicitationId] + end + + test "a handler-supplied elicitationId rides the leg unchanged" do + transport = AnsweringTransport.new + tool = Tool.define(name: "url_asker") do |server_context:| + if server_context.input_response("auth") + Tool::Response.new([{ type: "text", text: "authed" }]) + else + Server::InputRequiredResult.new( + input_requests: { + "auth" => { + method: Methods::ELICITATION_CREATE, + params: { mode: "url", url: "https://example.com/auth", elicitationId: "mine" }, + }, + }, + ) + end + end + session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) + + session.handle(tool_call_request(name: "url_asker")) + + assert_equal "mine", transport.sent.first[:params][:elicitationId] + end + + test "a client answering a fulfilment leg with an error fails the original request" do + session = legacy_session(tools: [@greeting_tool], transport: RejectingTransport.new) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "a handler that never completes fails after the round cap" do + tool = Tool.define(name: "greedy") do |server_context:| + round = (server_context.request_state || "0").to_i + 1 + Server::InputRequiredResult.new( + input_requests: { "k#{round}" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: round.to_s, + ) + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "greedy")) + + assert_equal(-32603, response.dig(:error, :code)) + assert_match(/#{Server::LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds/, response.dig(:error, :message)) + end + + test "embedded requests exceeding the declared capabilities fail without contacting the client" do + transport = AnsweringTransport.new + session = legacy_session(tools: [@greeting_tool], transport: transport, capabilities: {}) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + assert_match(/elicitation/, response.dig(:error, :message)) + assert_empty transport.sent + end + + test "opting out restores the strict legacy rejection" do + session = legacy_session( + tools: [@greeting_tool], + transport: AnsweringTransport.new, + input_required_legacy_shim: false, + ) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "a session-less legacy request is still rejected" do + server = Server.new(name: "shim_test", tools: [@greeting_tool]) + + response = server.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "modern requests keep the input_required result untouched" do + session = legacy_session(tools: [@greeting_tool], transport: transport = AnsweringTransport.new) + + response = session.handle({ + jsonrpc: "2.0", + id: 1, + method: Methods::TOOLS_CALL, + params: { + name: "greeter", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, + }, + }, + }) + + assert_equal "input_required", response.dig(:result, :resultType) + assert_empty transport.sent + end + + private + + def legacy_session(tools:, transport:, capabilities: { elicitation: { form: {} } }, + input_required_legacy_shim: true, request_state_security: nil) + server = Server.new( + name: "shim_test", + tools: tools, + input_required_legacy_shim: input_required_legacy_shim, + request_state_security: request_state_security, + ) + session = ServerSession.new(server: server, transport: transport, session_id: "legacy") + session.store_client_info(client: { name: "legacy" }, capabilities: capabilities) + session + end + + def tool_call_request(name: "greeter") + { jsonrpc: "2.0", id: 1, method: Methods::TOOLS_CALL, params: { name: name, arguments: {} } } + end + end +end From c8116fbcf03a7bfbbbb53c02371a3c5f048b9adf Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 14 Aug 2026 04:12:54 +0900 Subject: [PATCH 204/247] Drive the `http-standard-headers` scenario in the conformance client ## Motivation and Context The 2026-07-28 requirement set scores `http-standard-headers` (SEP-2243) on the client leg, but `conformance/client.rb` had no case for it: the scenario hit the unknown-scenario abort, the client exited 1, and every `Mcp-Method` / `Mcp-Name` check reported SKIPPED ("Client did not send a ... request"), failing the scenario in tier-check. The 2026-08-14 tier audit flagged this as the only scored conformance failure (client leg 49/50). The transport already sends the standard headers on the modern path; what was missing is a driver that exercises each request kind the harness observes. The new case issues `tools/list`, `tools/call` (the `test_headers` fixture tool), `resources/list`, `resources/read` (the first listed resource URI), `prompts/list`, and `prompts/get` (the first listed prompt), letting the harness see the `Mcp-Method` header on every POST and the `Mcp-Name` header on the three name-carrying requests. The `initialize` / `notifications/initialized` rows stay SKIPPED by design: the 2026-07-28 wire is stateless, so those requests are never sent. ## How Has This Been Tested? `conformance client --scenario http-standard-headers --spec-version 2026-07-28` passes 9/9 checks, and the full `--requirements 2026-07-28` client leg exits 0 with every scored scenario passing (435 checks; the only failures are the baselined not-scored DPoP/WIF extensions). RuboCop is clean. ## Breaking Changes None. The change only adds a scenario driver to the conformance client fixture; library code is untouched. --- conformance/client.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/conformance/client.rb b/conformance/client.rb index c04d272..b568e2e 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -238,6 +238,21 @@ begin # calling only the valid one shows the invalid definitions did not block it. client.tools client.call_tool(name: "valid_tool", arguments: { "message" => "hello" }) + when "http-standard-headers" + # SEP-2243: the harness inspects the `Mcp-Method` header on every POST and the `Mcp-Name` header on + # the name-carrying requests, so drive one of each standard request kind. + tools = client.tools + tool = tools.find { |t| t.name == "test_headers" } || tools.first + abort("No tool exposed by conformance server for #{scenario}") unless tool + client.call_tool(tool: tool, arguments: {}) + + resource = client.resources.first + abort("No resource exposed by conformance server for #{scenario}") unless resource + client.read_resource(uri: resource["uri"]) + + prompt = client.prompts.first + abort("No prompt exposed by conformance server for #{scenario}") unless prompt + client.get_prompt(name: prompt["name"]) when "json-schema-ref-no-deref" # SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing. client.tools From 6a19688cf31fedfa38fba94f32450c89ab2f3328 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 14 Aug 2026 04:14:48 +0900 Subject: [PATCH 205/247] [Doc] Add prompts/resources list-changed notification examples to README.md ## Motivation and Context The SDK tiering system (SEP-1730) requires Tier 1 SDKs to document all non-experimental features with examples. The 2026-08-14 documentation coverage audit found the last two example gaps: the Notifications section documents `notify_prompts_list_changed` and `notify_resources_list_changed` in prose, but only `notify_tools_list_changed` appeared in a code example, leaving the prompts and resources variants PARTIAL. The Usage Example in the notifications chapter now defines a prompt and a resource and broadcasts the matching list-changed notification for each, alongside the existing tools example. The block signatures mirror the canonical `define_prompt` / `define_resource` examples earlier in the README.md. ## How Has This Been Tested? Documentation-only change. The snippet was verified end to end against the current public API: both definitions serve `prompts/get` / `resources/read` responses and all three notify calls run without error. ## Breaking Changes None. This only extends a code example in README.md. --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 20b3143..0a3e5d8 100644 --- a/README.md +++ b/README.md @@ -2226,6 +2226,18 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) # When tools change, notify clients server.define_tool(name: "new_tool") { |**args| { result: "ok" } } server.notify_tools_list_changed + +# When prompts change, notify clients +server.define_prompt(name: "new_prompt") do |args, server_context:| + MCP::Prompt::Result.new(messages: []) +end +server.notify_prompts_list_changed + +# When resources change, notify clients +server.define_resource(uri: "resource://new", name: "new_resource", mime_type: "text/plain") do + [MCP::Resource::TextContents.new(uri: "resource://new", mime_type: "text/plain", text: "contents")] +end +server.notify_resources_list_changed ``` You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. From a4a885683c36a8150d501e00ed3d805221235d0f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 5 Aug 2026 02:48:29 +0900 Subject: [PATCH 206/247] Bound automatic pagination in the MCP client ## Motivation and Context `MCP::Client#tools`, `#resources`, `#resource_templates`, and `#prompts` follow `nextCursor` until the server stops offering one. The `seen` set in `fetch_all_pages` catches a server that repeats or cycles cursors, but a fresh cursor on every response walks past that guard, and the client keeps requesting and retaining pages for as long as the server keeps producing them. The transports' per-message limits do not help here, since each individual page can be small. `MCP::Client.new` now takes `max_pages:`, defaulting to `MAX_PAGES` (1000). Reaching it raises `PaginationLimitError` rather than truncating the collection silently, since a short array would be indistinguishable from a server that genuinely ran out of items. The single-page `list_*` methods are unchanged, so callers who need their own policy still have one. ## How Has This Been Tested? New tests in `test/mcp/client_test.rb` cover a server that returns a distinct cursor per page, the boundary where a server stops exactly at `max_pages`, and the argument validation. The existing repeat-cursor and cycling-cursor tests are unaffected. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes An all-pages call against a server offering more than 1000 pages now raises `MCP::Client::PaginationLimitError` instead of continuing. Pass a larger `max_pages:` to `MCP::Client.new` if that is expected. --- README.md | 9 +++++-- lib/mcp/client.rb | 34 ++++++++++++++++++++++++-- test/mcp/client_test.rb | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 439233d..bf21f70 100644 --- a/README.md +++ b/README.md @@ -2386,13 +2386,18 @@ If your application needs the complete collection regardless of how the server i `client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate through all pages and return a plain array of items, guaranteeing the full collection regardless of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round -trips per call and break out of the pagination loop if the server returns the same `nextCursor` -twice in a row as a safety measure. +trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor` +it has already sent, and it stops after `max_pages` pages. ```ruby tools = client.tools # => Array of every tool on the server. ``` +`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods +will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that +point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it +if you legitimately expect more pages than that. + Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need fine-grained iteration (e.g. to stream-process pages without loading everything into memory). diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index e1de318..7c9d8b9 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -12,6 +12,12 @@ require_relative "result_type" module MCP class Client + # Upper bound on the number of pages the all-pages methods (`tools`, `resources`, + # `resource_templates`, `prompts`) will walk. The cursor guard in `fetch_all_pages` only + # stops a server that repeats or cycles cursors; one that returns a fresh `nextCursor` on + # every response would otherwise be followed indefinitely, growing the retained pages with it. + MAX_PAGES = 1_000 + class ServerError < StandardError attr_reader :code, :data @@ -52,6 +58,11 @@ module MCP # server-returned JSON-RPC error, which is raised as `ServerError`. class ValidationError < StandardError; end + # Raised when an all-pages method reaches `max_pages` while the server is still offering + # another cursor. Use the single-page `list_*` methods to walk such a collection with + # a policy of your own. + class PaginationLimitError < StandardError; end + # Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of # a final result. The result is not an error on the wire: it asks the client to fulfill the server's # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with @@ -108,6 +119,8 @@ module MCP # @param transport [Object] The transport object to use for communication with the server. # The transport should be a duck type that responds to `send_request`. See the README for more details. # @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds. + # @param max_pages [Integer] Maximum number of pages the all-pages methods ({#tools}, {#resources}, + # {#resource_templates}, {#prompts}) will walk before raising {MCP::Client::PaginationLimitError}. # # Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`, # `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers @@ -117,8 +130,15 @@ module MCP # @example # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") # client = MCP::Client.new(transport: transport) - def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS) + def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, max_pages: MAX_PAGES) + # `nil` or a non-positive value would make the pagination unbounded and silently + # disable the protection, so reject it up front. + unless max_pages.is_a?(Integer) && max_pages > 0 + raise ArgumentError, "max_pages must be a positive Integer" + end + @transport = transport + @max_pages = max_pages # Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways # the server can ask for input: a real server-to-client request, and an embedded request inside # a SEP-2322 `input_required` result. @@ -301,6 +321,7 @@ module MCP # Cancelling it aborts whichever page is currently in flight; pages already returned are kept, # but the call raises `MCP::CancelledError` instead of returning the partial set. # @return [Array] An array of available tools. + # @raise [MCP::Client::PaginationLimitError] If the server offers more than `max_pages` pages. # # @example # tools = client.tools @@ -342,6 +363,7 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resources. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. def resources(cancellation: nil) # TODO: consider renaming to `list_all_resources`. fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources) @@ -377,6 +399,7 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resource templates. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. def resource_templates(cancellation: nil) # TODO: consider renaming to `list_all_resource_templates`. fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates) @@ -412,6 +435,7 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available prompts. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. def prompts(cancellation: nil) # TODO: consider renaming to `list_all_prompts`. fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts) @@ -695,7 +719,8 @@ module MCP # Walks every page of a list endpoint, following `next_cursor`, and returns # the page results. The `seen` set guards against a server that repeats or - # cycles cursors, so the loop always terminates. + # cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every + # time, so the loop always terminates. def fetch_all_pages pages = [] seen = Set.new @@ -707,6 +732,11 @@ module MCP next_cursor = page.next_cursor break if next_cursor.nil? || seen.include?(next_cursor) + if pages.size >= @max_pages + raise PaginationLimitError, "Server returned more than #{@max_pages} pages; pass a larger `max_pages:` to " \ + "`MCP::Client.new` if this is expected." + end + seen << next_cursor cursor = next_cursor end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index d944698..bbd0652 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -1645,6 +1645,59 @@ module MCP assert_equal(2, tools.size) end + def test_tools_raises_when_the_server_offers_more_pages_than_max_pages + transport = mock + + pages = (1..3).map do |n| + { + "result" => { + "tools" => [{ "name" => "tool#{n}", "description" => "tool#{n}", "inputSchema" => {} }], + "nextCursor" => "cursor#{n}", + }, + } + end + + # A fresh cursor on every response defeats the repeat guard, so only `max_pages` stops the walk. + transport.expects(:send_request).times(3).returns(*pages) + + client = Client.new(transport: transport, max_pages: 3) + + error = assert_raises(Client::PaginationLimitError) { client.tools } + assert_equal( + "Server returned more than 3 pages; pass a larger `max_pages:` to `MCP::Client.new` if this is expected.", + error.message, + ) + end + + def test_tools_returns_every_page_when_the_server_stops_exactly_at_max_pages + transport = mock + + page1 = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + page2 = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + transport.expects(:send_request).twice.returns(page1, page2) + + client = Client.new(transport: transport, max_pages: 2) + tools = client.tools + + assert_equal(["tool1", "tool2"], tools.map(&:name)) + end + + def test_raises_argument_error_when_max_pages_is_not_positive + error = assert_raises(ArgumentError) { Client.new(transport: mock, max_pages: 0) } + + assert_equal("max_pages must be a positive Integer", error.message) + end + def test_tools_breaks_when_server_cycles_between_cursors transport = mock From 48b7aaacd9ba1fd366a3a0922051d395ce8d5d3a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 14 Aug 2026 05:34:33 +0900 Subject: [PATCH 207/247] Stop negotiating modern protocol versions through the `initialize` handshake ## Motivation and Context Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761 A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are both gated on the per-request `_meta` envelope, which such a connection never carries, so strictly validating clients reject every result as missing the REQUIRED `resultType`. The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish a session via `initialize` (`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry; the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too, and counter-offers `LATEST_HANDSHAKE_VERSION`. `initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list (`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know, is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on `2025-11-25`, where `resultType` does not exist and its absence is the correct shape, resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is no longer constructible, so the gates are correct by construction. The client side is symmetrized the same way, matching both reference SDKs: the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default, passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError` (validated before the stdio transport spawns its child process), and a server answering `initialize` with a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to the discovery probe and never downgrades it into the legacy fallback: the probe's failure is the real answer and propagates, instead of the fallback dying on the handshake guard. The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`), and the handshake list is derived from the stable and modern lists so the era partition cannot drift when the next revision lands. Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land on a deprecating version and the call site the warnings used to sit on had become unreachable. On the server the answer is removal: `Configuration#protocol_version` scopes the handshake, so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction. Accepting one would configure nothing (a modern version has no handshake to pin), and neither reference SDK carries a config value that is accepted but inert: Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions` is consumed by its own era's mechanism. With no path left onto a deprecating revision, the server-side deprecation warning plumbing became unreachable and is removed along with it. Keying those warnings on a modern-era session instead was tried and rejected, because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line would then carry a deprecation warning for doing exactly what the revision prescribes (a single test file produced over a thousand of them). A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered, and such a request continues to be served at the negotiated revision rather than rejected. Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`, through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with its consequence, which is that no `resultType` appears on those results. Two details follow from the pin being handshake-scoped. Reading it back when nothing is set now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era, so the getter no longer hands out a value its own writer rejects and round-tripping the setting keeps working; only `Server#validate!` reads that default, through three comparisons that answer the same for either value, so nothing else moves. And the rejection names its reason instead of listing the accepted versions, because this is the error an upgrade from a release that accepted the value lands on, which makes it the migration note a bare list would not be. `MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now, and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be a breaking change for no gain. The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper now use the handshake version as well, and the README section on `protocol_version` describes the pin as handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default, and `2026-07-28` reached through `server/discover` with no configuration needed. ## How Has This Been Tested? New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType` (the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer, the `ArgumentError` for an explicitly modern version on the legacy connect (asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult` on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities. Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version, the answer is an older one, and the connection speaks the answer afterwards (over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption from an echo, because every one of them had the server answer with the version the client had just offered. Both new tests were confirmed to fail when the client is made to report its own offer instead. Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked: the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer, and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires. The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version, or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths, since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests on the constructor and the setter, asserting the reason in the message rather than the list of accepted values. The default-pin test now also round-trips what it reads back into a fresh `Configuration`, which is what the old default could not do. The default move was checked for neutrality outside the suite as well, by building a server that uses every feature `Server#validate!` gates on that value (`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations) and confirming all of them are still accepted. ## Breaking Changes Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through the handshake are counter-offered `2025-11-25` and are served at that revision, matching the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now raises `ArgumentError` (the pin scopes the handshake, which cannot land there); the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway. Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`; nothing in this SDK behaves differently for either value, but code comparing that reader against a literal will see the change. The client no longer offers modern versions on the legacy handshake and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision; the client-side warning on a modern connect replaces them. --- README.md | 8 +- docs/building-clients.md | 4 +- lib/mcp/client/http.rb | 28 +++- lib/mcp/client/stdio.rb | 29 +++-- lib/mcp/configuration.rb | 56 ++++++-- lib/mcp/protocol_deprecations.rb | 9 ++ lib/mcp/server.rb | 37 ++---- .../transports/streamable_http_transport.rb | 17 +-- lib/mcp/server_session.rb | 4 - test/initialize_params_test_helper.rb | 6 +- test/mcp/client/http_test.rb | 121 +++++++++++++++++- test/mcp/client/stdio_test.rb | 107 +++++++++++++++- test/mcp/configuration_test.rb | 37 ++++-- .../server/transports/stdio_transport_test.rb | 8 +- .../streamable_http_transport_test.rb | 30 ++++- test/mcp/server_cancellation_test.rb | 2 +- test/mcp/server_notification_test.rb | 46 ++----- test/mcp/server_roots_test.rb | 21 +-- test/mcp/server_sampling_test.rb | 29 +---- test/mcp/server_test.rb | 80 ++++++------ 20 files changed, 472 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index bf21f70..f335bf9 100644 --- a/README.md +++ b/README.md @@ -585,8 +585,7 @@ configuration = MCP::Configuration.new(protocol_version: "2024-11-05") MCP::Server.new(name: "test_server", configuration: configuration) ``` -If no protocol version is specified, the latest stable version will be applied by default. -The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft). +If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default. This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: @@ -596,6 +595,11 @@ MCP::Configuration.new(protocol_version: nil) If an invalid `protocol_version` value is set, an `ArgumentError` is raised. +The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model, +`2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`; +a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs. +Clients reach `2026-07-28` through `server/discover` and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. + Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. ### Exception Reporting diff --git a/docs/building-clients.md b/docs/building-clients.md index 9dfaf0d..6b4c7d0 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -22,7 +22,7 @@ Call `MCP::Client#connect` to perform the MCP [initialization handshake](https:/ ```ruby client.connect -# => { "protocolVersion" => "2026-07-28", "capabilities" => {...}, "serverInfo" => {...} } +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } client.connected? # => true client.server_info # => cached InitializeResult @@ -99,7 +99,7 @@ After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` heade ```ruby http_transport.session_id # => "abc123..." -http_transport.protocol_version # => "2026-07-28" +http_transport.protocol_version # => "2025-11-25" ``` If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 1a7b4d4..e189c74 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -327,8 +327,9 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer. Defaults - # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error @@ -341,6 +342,9 @@ module MCP def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? + # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } case mode @@ -559,7 +563,7 @@ module MCP attr_reader :headers def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION response = send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -593,7 +597,10 @@ module MCP @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake + # settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) clear_session raise RequestHandlerError.new( "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", @@ -602,8 +609,6 @@ module MCP ) end - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - begin send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -647,6 +652,10 @@ module MCP ) end + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + @server_info = result @connected = true @server_info @@ -658,8 +667,13 @@ module MCP # version as well: during the 2026-07-28 rollout a server may answer discovery while # only serving legacy versions. def connect_auto(client_info:, protocol_version:, capabilities:) - connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) end diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index d26528f..fee1a3d 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -77,8 +77,9 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer. Defaults - # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, @@ -91,6 +92,9 @@ module MCP def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? + # Validated before `start` so a pure argument error never spawns the server process. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } @@ -221,7 +225,7 @@ module MCP private def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION init_request = { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -257,9 +261,11 @@ module MCP @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) # Per spec, if the client does not support the server's returned protocol version, - # the client SHOULD disconnect. Roll back the cached `InitializeResult` before raising + # the client SHOULD disconnect. A modern version is rejected along with unknown ones: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising # so a retry starts without a stale `server_info`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation @server_info = nil @@ -270,8 +276,6 @@ module MCP ) end - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - begin notification = { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -317,6 +321,10 @@ module MCP ) end + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + @server_info = result @server_info end @@ -327,8 +335,13 @@ module MCP # version as well: during the 2026-07-28 rollout a server may answer discovery while # only serving legacy versions. def connect_auto(client_info:, protocol_version:, capabilities:) - connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) end diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 0dac223..4d4b78e 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -9,19 +9,40 @@ module MCP ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" - # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575). - # 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize` - # handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern - # lifecycle, where each request carries its own version in `_meta` and is validated against this list - # independently, with no handshake. + # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575), + # where each request carries its own version in `_meta` and is validated against this list independently, + # with no handshake. These are reachable only through `server/discover` and the per-request envelope. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze + # Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition + # (handshake = stable minus modern) cannot drift when a new revision lands. + # Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish + # a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request + # in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version: + # a client asking `initialize` for one is counter-offered + # `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs. + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze + LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first + class << self def modern_protocol_version?(version) SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version) end + + def handshake_protocol_version?(version) + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version) + end + + # The one statement of the client-side handshake contract, shared by every transport: + # a modern version cannot ride the legacy `initialize` handshake. + def reject_modern_handshake_version!(version) + return unless version && modern_protocol_version?(version) + + raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \ + "`initialize` handshake; use `mode: :modern` (or `:auto`) instead" + end end attr_writer :exception_reporter, :around_request @@ -78,8 +99,12 @@ module MCP @validate_tool_call_results = validate_tool_call_results end + # The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake + # settles on by default. Reading the newest version of any era here would hand back a value + # the writer rejects, and no caller wants the modern revision: a modern connection carries + # its version on every request instead of consulting configuration. def protocol_version - @protocol_version || LATEST_STABLE_PROTOCOL_VERSION + @protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION end def protocol_version? @@ -174,11 +199,24 @@ module MCP private + # A pin scopes the `initialize` handshake, so only handshake versions are accepted: + # a modern version has no handshake to pin (its version rides every request in `_meta`), + # and accepting one here would configure nothing. Failing at construction beats + # a setting that silently does not apply. def validate_protocol_version!(protocol_version) - unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) - message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}" - raise ArgumentError, message + return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version) + + # A version this SDK serves, rejected only for where it was set, deserves the reason and + # the alternative rather than a list it is missing from: this is the error an upgrade from + # a release that accepted the value lands on, so it doubles as the migration note. + if self.class.modern_protocol_version?(protocol_version) + raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \ + "the pin scopes the `initialize` handshake, which never negotiates a modern version. " \ + "Modern clients carry their version on every request and need no pin; remove the setting." end + + raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \ + "or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}" end def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb index ee56161..f79e727 100644 --- a/lib/mcp/protocol_deprecations.rb +++ b/lib/mcp/protocol_deprecations.rb @@ -3,6 +3,15 @@ require_relative "configuration" module MCP + # Warning texts for the features SEP-2577 deprecates at 2026-07-28. + # + # Only the client emits these, from the modern connect, where the capabilities it declares are + # the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on + # a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach + # a version these apply to. `LOGGING_MESSAGE` in particular has no caller left + # (a modern client declares no logging capability, and `notify_log_message` on that wire is + # the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and + # callable for embedders, and because deleting it would be a breaking change for no gain. module ProtocolDeprecations extend self diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5fa024b..2eff700 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -403,8 +403,6 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil) - warn_if_deprecated_protocol_feature(:logging, uplevel: 1) - return unless @transport return unless logging_message_notification&.should_notify?(level) @@ -425,8 +423,6 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def roots_list_changed_handler(&block) - warn_if_deprecated_protocol_feature(:roots, uplevel: 1) - @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end @@ -591,13 +587,6 @@ module MCP return ->(params) { handle_cancelled_notification(params, session: session) } end - case method - when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED - warn_if_deprecated_protocol_feature(:roots, session: session, uplevel: 2) - when Methods::LOGGING_SET_LEVEL - warn_if_deprecated_protocol_feature(:logging, session: session, uplevel: 2) - end - handler = @handlers[method] unless handler instrument_call("unsupported_method", server_context: { request: request }) do @@ -1045,10 +1034,19 @@ module MCP end protocol_version = params[:protocolVersion] - negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) + # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is + # defined by carrying its own version on every request in `_meta`, with no handshake, + # so asking `initialize` for one (or for a version this server does not know) is answered with + # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions + # stay reachable through `server/discover` and the per-request envelope. The counter-offer is + # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there), + # and the latest handshake version otherwise. + negotiated_version = if Configuration.handshake_protocol_version?(protocol_version) protocol_version - else + elsif configuration.protocol_version? configuration.protocol_version + else + Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION end info = server_info.reject do |property| @@ -1160,19 +1158,6 @@ module MCP {} end - def warn_if_deprecated_protocol_feature(feature, session: nil, uplevel: 1) - protocol_version = effective_deprecation_protocol_version(session) - MCP::ProtocolDeprecations.warn_for(feature, protocol_version: protocol_version, uplevel: uplevel) - end - - def effective_deprecation_protocol_version(session) - session&.protocol_version || @client_protocol_version || explicit_protocol_version - end - - def explicit_protocol_version - configuration.protocol_version if configuration.protocol_version? - end - def list_tools(request) page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 48e8de2..e420dc0 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -277,12 +277,12 @@ module MCP # An empty header value is malformed rather than a version claim, so it stays on the legacy path # and fails legacy header validation as before. # - # 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version - # alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session - # (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose - # body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is - # sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests - # that get the modern path's error shape). + # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it, + # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it): + # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, + # and DELETE termination keep working), and a session-less POST whose body is `initialize` is + # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic + # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] if header_version && !header_version.empty? && !stable_only_version?(header_version) unless MCP::Configuration.modern_protocol_version?(header_version) @@ -1414,8 +1414,9 @@ module MCP body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER end - # A version negotiable only through the legacy handshake, with no modern meaning. - # Dual-era versions (2026-07-28) appear in both lists and need further disambiguation. + # A version with no modern meaning, whose header can only accompany legacy traffic. + # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it, + # but requests of an established legacy session may stamp it - so that value needs further disambiguation. def stable_only_version?(version) MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) && !MCP::Configuration.modern_protocol_version?(version) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 3149736..d12b18c 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -132,7 +132,6 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def list_roots(related_request_id: nil, timeout: nil) - @server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:roots) @@ -159,7 +158,6 @@ module MCP # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider # APIs instead. def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) - @server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) params = @server.build_sampling_params(client_capabilities, **kwargs) @@ -274,8 +272,6 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil, related_request_id: nil) - @server.send(:warn_if_deprecated_protocol_feature, :logging, session: self, uplevel: 2) - # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level # does not apply (SEP-2575). diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb index 6e6d6b2..ccee933 100644 --- a/test/initialize_params_test_helper.rb +++ b/test/initialize_params_test_helper.rb @@ -2,10 +2,12 @@ module InitializeParamsTestHelper # `initialize` params satisfying the fields required by the MCP schema - # (protocolVersion, capabilities, and clientInfo). + # (protocolVersion, capabilities, and clientInfo). Offers the latest handshake version + # so helper-built sessions run the version they request; the counter-offer for + # modern versions has its own dedicated tests. def initialize_params(**overrides) { - protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION, + protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "test-client", version: "1.0.0" }, }.merge(overrides) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 7d0d859..87cdb55 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1630,6 +1630,52 @@ module MCP client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) end + def test_adopts_a_counter_offered_protocol_version_from_the_handshake + # A server may answer `initialize` with a version other than the one offered, and everything + # after the handshake speaks the answer rather than the offer. This server counter-offers + # a handshake version to clients asking `initialize` for a modern one, which only resolves + # anything because clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + offered = nil + stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: counter_offered } }.to_json, + ) + stub_notification + + client.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, client.protocol_version) + + # Scoped to the body as well: `notifications/initialized` already went out under + # the adopted version, and a header-only stub would count that too. + header_stub = stub_request(:post, url).with( + headers: { "MCP-Protocol-Version" => counter_offered }, + ) do |req| + JSON.parse(req.body)["method"] == "tools/list" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_requested(header_stub) + end + def test_does_not_send_protocol_version_header_before_initialize stub_request(:post, url) .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } @@ -1884,14 +1930,14 @@ module MCP .with do |req| body = JSON.parse(req.body) body["method"] == "initialize" && - body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION && + body["params"]["protocolVersion"] == MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION && body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } && body["params"]["capabilities"] == {} end .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json, + body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, ) client.connect @@ -1927,9 +1973,31 @@ module MCP assert_requested(notification_stub) end - def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 - notification_stub = stub_notification + def test_connect_offers_the_latest_handshake_version_by_default + # The handshake negotiates legacy versions only (SEP-2575 era model); modern versions are + # selected via `mode: :modern`/`:auto`, so the default offer is the latest handshake version. + offered = nil + init_stub = stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + stub_notification + client.connect + + assert_requested(init_stub) + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients + # refuse a modern counter-offer the same way. init_stub = stub_request(:post, url) .with { |req| JSON.parse(req.body)["method"] == "initialize" } .to_return( @@ -1938,12 +2006,20 @@ module MCP body: { result: { protocolVersion: "2026-07-28" } }.to_json, ) - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do - client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + error = assert_raises(RequestHandlerError) do + client.connect end + assert_includes(error.message, "2026-07-28") assert_requested(init_stub) - assert_requested(notification_stub) + end + + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + error = assert_raises(ArgumentError) do + client.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") end def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older @@ -2167,6 +2243,37 @@ module MCP assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") } end + def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_version + # An explicitly requested modern version is never downgraded by the fallback: + # the legacy handshake cannot negotiate it, so the probe's failure is the real answer. + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect(mode: :auto, protocol_version: "2026-07-28") + end + + assert_includes(error.message, "no mutually supported modern protocol version") + refute_predicate(client, :connected?) + end + + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every + # modern connection speaks. + stub_discover + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + client.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(client, :modern?) + end + def test_reconnect_after_close stub_initialize stub_notification diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 4a04bfe..3b16040 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -818,7 +818,56 @@ module MCP stdout_write.close end - def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 + def test_connect_adopts_a_counter_offered_protocol_version + # A server may answer `initialize` with a version other than the one offered, and the connection + # then speaks the answer rather than the offer. This server counter-offers a handshake version + # to clients asking `initialize` for a modern one, which only resolves anything because + # clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + offered = nil + + server_thread = Thread.new do + init_request = JSON.parse(stdin_read.gets) + offered = init_request.dig("params", "protocolVersion") + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: counter_offered }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, transport.protocol_version) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition (SEP-2575 era model), + # and the TypeScript and Python clients refuse a modern counter-offer the same way. stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe stderr_read, _ = IO.pipe @@ -836,12 +885,14 @@ module MCP result: { protocolVersion: "2026-07-28" }, )) stdout_write.flush - stdin_read.gets end - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + error = assert_raises(RequestHandlerError) do transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) end + + assert_includes(error.message, "2026-07-28") + refute_predicate(transport, :connected?) ensure server_thread.join stdin_read.close @@ -850,6 +901,19 @@ module MCP stdout_write.close end + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + # Validated before the child process is spawned: a pure argument error must not leave + # an orphaned server process behind. + Open3.expects(:popen3).never + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + error = assert_raises(ArgumentError) do + transport.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") + end + def test_connect_raises_on_jsonrpc_error_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1498,6 +1562,43 @@ module MCP end end + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + result: { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + }, + })) + stdout_write.flush + end + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(transport, :modern?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + def test_connect_auto_falls_back_to_the_legacy_handshake stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 6aa10cc..058bc19 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -36,15 +36,21 @@ module MCP assert_equal test_context, reported_context end - # https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15 test "initializes with default protocol version" do + # The unset pin reads as the version the handshake settles on, which is also the only kind of + # value the writer accepts: reading back a version that cannot be set again would break code + # that round-trips the setting. config = Configuration.new - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version + refute_predicate config, :protocol_version? + assert_nothing_raised { Configuration.new(protocol_version: config.protocol_version) } end - test "uses the draft protocol version when protocol_version is set to nil" do + test "uses the default protocol version when protocol_version is set to nil" do config = Configuration.new(protocol_version: nil) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version end test "raises ArgumentError when setting the draft protocol version" do @@ -54,7 +60,7 @@ module MCP Configuration.new(protocol_version: "DRAFT-2025-v3") end - assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "raises ArgumentError when protocol_version is not a supported protocol version" do @@ -63,7 +69,7 @@ module MCP custom_version = "2025-03-27" config.protocol_version = custom_version end - assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "exposes the SEP-2575 modern protocol versions" do @@ -73,11 +79,20 @@ module MCP refute Configuration.modern_protocol_version?("2025-11-25") end - test "accepts 2026-07-28 as the protocol version" do - # 2026-07-28 serves both lifecycles of the dual-era model (SEP-2575), so it is negotiable through - # the legacy `initialize` handshake and settable as the fallback version. - config = Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) - assert_equal "2026-07-28", config.protocol_version + test "rejects a modern version as the protocol version pin" do + # The pin scopes the `initialize` handshake; a modern version has no handshake to pin + # (its version rides every request in `_meta`), so accepting it would configure nothing. + # The message names that reason instead of listing the accepted values, because this is + # the error an upgrade from a release that accepted the value lands on. + exception = assert_raises(ArgumentError) do + Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) + end + + assert_includes(exception.message, "is a modern protocol version and cannot be pinned here") + assert_includes(exception.message, "remove the setting") + + config = Configuration.new + assert_raises(ArgumentError) { config.protocol_version = "2026-07-28" } end test "raises ArgumentError when protocol_version is not a boolean value" do diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index eef0a7c..683e2ae 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -563,15 +563,15 @@ module MCP assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) end - test "initialize negotiating 2026-07-28 still locks the legacy era" do - # 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through - # the legacy handshake locks `:legacy`, so a later modern envelope is still rejected. + test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on + # the legacy lifecycle it selected, so a later modern envelope is still rejected. responses = run_transport_session([ initialize_request(id: 1, protocol_version: "2026-07-28"), modern_tools_list_request(id: 2), ]) - assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion) + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion) assert_equal :legacy, session_era assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 3085356..26b8906 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -248,7 +248,7 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do @@ -1902,7 +1902,9 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + # The body's requested version drives negotiation (not the older header); + # the default modern request is counter-offered the latest handshake version. + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do @@ -2027,7 +2029,9 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, ) init_response = @transport.handle_request(init_request) - assert_equal "2026-07-28", JSON.parse(init_response[2][0])["result"]["protocolVersion"] + # The handshake never negotiates a modern version (SEP-2575 era model); + # the session proceeds on the counter-offered 2025-11-25. + assert_equal "2025-11-25", JSON.parse(init_response[2][0])["result"]["protocolVersion"] session_id = init_response[1]["mcp-session-id"] request = create_rack_request( @@ -5784,9 +5788,10 @@ module MCP assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) end - test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28" do + test "sessionless POST initialize with the dual-era header stays legacy and is counter-offered" do # The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is - # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle. + # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle + # and is counter-offered the latest handshake version (SEP-2575 era model). request = create_rack_request( "POST", "/", @@ -5797,10 +5802,16 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] refute_nil response[1]["mcp-session-id"] - assert_equal "2026-07-28", JSON.parse(response[2][0]).dig("result", "protocolVersion") + assert_equal "2025-11-25", JSON.parse(response[2][0]).dig("result", "protocolVersion") end test "session-bound POST with the dual-era header stays on the legacy path" do + # A deliberately lenient reading. The header names a version the handshake refused to + # negotiate, so it disagrees with the session; the session wins and the request is served + # at the negotiated revision, unstamped. Rejecting instead would read the spec's + # "invalid or unsupported MCP-Protocol-Version MUST be 400" as covering a version this + # server does in fact support (through the modern lifecycle), and would break a client + # over what is a SHOULD on its side: sending the negotiated version rather than its own. session_id = initialize_test_session response = @transport.handle_request(create_rack_request( @@ -5815,6 +5826,13 @@ module MCP )) assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = sse_events(io).first + + # No `resultType`: the header claim does not move the session onto the modern wire. + refute(body["result"].key?("resultType")) end test "session-bound DELETE with the dual-era header terminates the legacy session" do diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index 9b1908a..a91bf1a 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -304,7 +304,7 @@ module MCP test "initialize request cannot be cancelled" do init_params = { - protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, clientInfo: { name: "test", version: "1.0" }, capabilities: {}, } diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 9e51a03..ffeb3a8 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -85,49 +85,25 @@ module MCP assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] end - test "#notify_log_message warns when configured protocol version is 2026-07-28" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) + test "#notify_log_message does not warn on any server configuration" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings; + # the notification itself still goes out (or is suppressed below the configured level). + server = Server.new(name: "test_server", version: "1.0.0") mock_transport = MockTransport.new(server) server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do server.notify_log_message(data: { error: "Connection Failed" }, level: "error") end assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] - end - test "#notify_log_message warns when configured protocol version is 2026-07-28 without transport" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do - server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - end - end - - test "#notify_log_message warns when configured protocol version is 2026-07-28 below configured level" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) - mock_transport = MockTransport.new(server) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do server.notify_log_message(data: { message: "test" }, level: "info") end - assert_empty mock_transport.notifications + assert_equal 1, mock_transport.notifications.size end test "#notify_log_message does not warn when configured protocol version is older" do @@ -144,7 +120,9 @@ module MCP end end - test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do + test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging + # is not deprecated; the below-configured-level suppression still applies. server = Server.new(name: "test_server", version: "1.0.0") mock_transport = MockTransport.new(server) session = ServerSession.new(server: server, transport: mock_transport) @@ -163,7 +141,7 @@ module MCP ) session.configure_logging(MCP::LoggingMessageNotification.new(level: "error")) - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do session.notify_log_message(data: { message: "test" }, level: "info") end diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index 283fbdd..39c4e8f 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -64,7 +64,9 @@ module MCP assert callback_called end - test "notifications/roots/list_changed warns when negotiated protocol version is 2026-07-28" do + test "notifications/roots/list_changed does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated. server = Server.new(name: "test", version: "1.0") server.handle({ jsonrpc: "2.0", @@ -77,7 +79,7 @@ module MCP }, }) - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do server.handle({ jsonrpc: "2.0", method: "notifications/roots/list_changed", @@ -176,13 +178,14 @@ module MCP assert_silent { session.list_roots(related_request_id: "req-1") } end - test "ServerSession#list_roots warns when negotiated protocol version is 2026-07-28 and client lacks roots" do - session = ServerSession.new(server: @server, transport: @mock_transport) + test "ServerSession#list_roots raises without a warning when the client lacks roots" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) session.store_client_info(client: { name: "test-client" }, capabilities: {}) - session.mark_initialized!(protocol_version: "2026-07-28") error = nil - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do error = assert_raises(RuntimeError) do session.list_roots(related_request_id: "req-1") end @@ -267,7 +270,9 @@ module MCP assert_equal("No active stream for roots/list request.", error.message) end - test "ServerSession#list_roots warns when session negotiated protocol version is 2026-07-28" do + test "ServerSession#list_roots does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated; the request itself still goes out. server = Server.new(name: "test", version: "1.0") transport = MockTransport.new(server) @@ -286,7 +291,7 @@ module MCP session: session, ) - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do session.list_roots(related_request_id: "req-1") end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index b2c84f5..c5d0d59 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -64,11 +64,14 @@ module MCP assert_equal "Response from LLM", result[:content][:text] end - test "create_sampling_message warns when session negotiated protocol version is 2026-07-28" do - @session.mark_initialized!(protocol_version: "2026-07-28") + test "create_sampling_message does not warn on a modern-era session" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) + session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) - assert_deprecation_warning(/MCP Sampling .*2026-07-28/) do - @session.create_sampling_message( + assert_no_deprecation_warning do + session.create_sampling_message( related_request_id: "req-1", messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, @@ -126,24 +129,6 @@ module MCP assert_equal("Client does not support sampling.", error.message) end - test "create_sampling_message warns when negotiated protocol version is 2026-07-28 and client lacks sampling" do - @session.store_client_info(client: { name: "test-client" }, capabilities: {}) - @session.mark_initialized!(protocol_version: "2026-07-28") - - error = nil - assert_deprecation_warning(/MCP Sampling .*2026-07-28/) 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, - ) - end - end - - assert_equal("Client does not support sampling.", error.message) - 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( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 480827e..6440815 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -430,9 +430,9 @@ module MCP refute_nil response[:result] end - test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do - # Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope: - # the handshake still belongs to the legacy lifecycle and locks that era. + test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version: the client is + # counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected. session = ServerSession.new(server: @server, transport: mock) response = @server.handle( @@ -445,7 +445,7 @@ module MCP session: session, ) - assert_equal "2026-07-28", response.dig(:result, :protocolVersion) + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion) assert_equal :legacy, session.era end @@ -724,7 +724,7 @@ module MCP jsonrpc: "2.0", id: 1, result: { - protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, capabilities: { prompts: { listChanged: true }, resources: { listChanged: true }, @@ -916,7 +916,7 @@ module MCP params: initialize_params(protocolVersion: "1999-01-01"), }) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "instrumentation data does not include client key when no clientInfo provided" do @@ -1715,7 +1715,10 @@ module MCP refute response.key?(:error) end - test "#configure_logging_level warns when negotiated protocol version is 2026-07-28" do + test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated, + # so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method + # and never reaches this handler. server = Server.new(tools: [TestTool]) server.handle( { @@ -1731,7 +1734,7 @@ module MCP ) response = nil - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do response = server.handle( { jsonrpc: "2.0", @@ -1827,35 +1830,6 @@ module MCP assert_includes response[:error][:data], "Server does not support logging" end - test "#configure_logging_level warns when configured protocol version is 2026-07-28 and server lacks logging capability" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(protocol_version: "2026-07-28"), - capabilities: { - tools: { listChanged: true }, - prompts: { listChanged: true }, - resources: { listChanged: true }, - }, - ) - - response = nil - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do - response = server.handle( - { - jsonrpc: "2.0", - id: 1, - method: "logging/setLevel", - params: { - level: "debug", - }, - }, - ) - end - - assert_equal(-32603, response[:error][:code]) - assert_includes response[:error][:data], "Server does not support logging" - end - test "#handle method with missing required top-level capability returns an error" do @server.capabilities = {} @@ -2207,7 +2181,7 @@ module MCP assert_equal local_exception_reporter, server.configuration.exception_reporter end - test "server uses default protocol version when not configured" do + test "server uses the latest handshake version when not configured" do request = { jsonrpc: "2.0", method: "initialize", @@ -2216,7 +2190,7 @@ module MCP } response = @server.handle(request) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server response does not include optional parameters when configured" do @@ -2606,7 +2580,7 @@ module MCP assert_equal "2025-06-18", response[:result][:protocolVersion] end - test "server falls back to default version when client requests unsupported version" do + test "server falls back to the latest handshake version when client requests unsupported version" do server = Server.new(name: "test_server") request = { @@ -2617,10 +2591,13 @@ module MCP } response = server.handle(request) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end - test "server negotiates 2026-07-28 when the client requests it via initialize" do + test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do + # Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries + # its own version on every request and has no handshake at all. The TypeScript and Python servers + # answer the same way. server = Server.new(name: "test_server") request = { @@ -2631,7 +2608,7 @@ module MCP } response = server.handle(request) - assert_equal "2026-07-28", response[:result][:protocolVersion] + assert_equal "2025-11-25", response[:result][:protocolVersion] end test "server removes description and icons from server_info when negotiating to 2025-06-18" do @@ -4356,6 +4333,23 @@ module MCP end end + test "results after a counter-offered modern initialize carry no resultType" do + # The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on + # 2025-11-25, where `resultType` does not exist, so its absence is the correct shape + # (clients MUST read an absent `resultType` as "complete" on that revision). + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + session = ServerSession.new(server: server, transport: mock) + + init_response = server.handle( + { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") }, + session: session, + ) + response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion) + refute response[:result].key?(:resultType) + end + test "server/discover carries resultType complete" do server = Server.new(name: "result_type_test", tools: [result_type_tool]) From feba4d4893b18fa0dfb6802c97154bcadd977db0 Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:55:27 +1200 Subject: [PATCH 208/247] Return Invalid Params for unknown prompts and missing prompt arguments An unknown prompt name and a missing required prompt argument are client input errors, but prompts/get raised them with the default Internal Error (-32603). The tools/call, resources/read, and completion/complete siblings already return Invalid Params (-32602) for the same not-found and missing-input conditions (resources/read via ResourceNotFoundError, per SEP-2164). Add an explicit error_code so both map to -32602 while keeping the descriptive message and the existing instrumentation labels. --- lib/mcp/prompt.rb | 9 ++++++++- lib/mcp/server.rb | 11 ++++++++++- test/mcp/server_test.rb | 6 ++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index 598a3d0..c2ed2db 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -111,8 +111,15 @@ module MCP missing = required_args - args.keys return if missing.empty? + # The explicit `error_code` maps a missing prompt argument to Invalid Params (-32602) rather + # than the default Internal Error (-32603); a missing required argument is client input, not a + # server fault. `error_type: :missing_required_arguments` keeps the descriptive message and + # instrumentation label. Mirrors `MCP::Server::ResourceNotFoundError`. raise MCP::Server::RequestHandlerError.new( - "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments + "Missing required arguments: #{missing.join(", ")}", + nil, + error_type: :missing_required_arguments, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, ) end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5fa024b..14a0ec6 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1260,7 +1260,16 @@ module MCP prompt = @prompts[prompt_name] unless prompt add_instrumentation_data(error: :prompt_not_found) - raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found) + # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than + # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and + # `completion/complete` siblings for the same not-found condition, while `error_type: + # :prompt_not_found` keeps the descriptive message and instrumentation label. + raise RequestHandlerError.new( + "Prompt not found #{prompt_name}", + request, + error_type: :prompt_not_found, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ) end add_instrumentation_data(prompt_name: prompt_name) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 480827e..3d89702 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1395,6 +1395,9 @@ module MCP } response = @server.handle(request) + # An unknown prompt is client input, so it maps to Invalid Params (-32602), not the + # default Internal Error (-32603); matches the tools/call and completion/complete siblings. + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal("Prompt not found unknown_prompt", response[:error][:data]) assert_instrumentation_data({ method: "prompts/get", error: :prompt_not_found }) end @@ -1411,6 +1414,9 @@ module MCP } response = @server.handle(request) + # A missing required argument is client input, so it maps to Invalid Params (-32602), + # not the default Internal Error (-32603). + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal "Missing required arguments: test_argument", response[:error][:data] assert_instrumentation_data({ method: "prompts/get", From af02717e2808c1c6f5c32c45867317f0ad0d294e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 15 Aug 2026 11:38:36 +0900 Subject: [PATCH 209/247] Release 1.2.0 This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with the SEP-2322, SEP-2549, and SEP-2243 features that revision builds on. Several entries under "Changed" are incompatible with 1.1.0 and ship in a minor release under the spec-conformance and security exceptions in VERSIONING.md. Servers no longer echo 2026-07-28 from `initialize` and counter-offer 2025-11-25 instead, `Configuration` accepts only handshake versions for its `protocol_version` pin, server-to-client requests are bounded by a timeout and refused outright on the modern lifecycle, the client bounds automatic pagination and its total SSE reconnection wait, and `prompts/get` answers Invalid Params where it used to answer Internal Error. Closes #512. --- CHANGELOG.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e2be4..b1466a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-08-15 + +This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with +the SEP-2322, SEP-2549, and SEP-2243 features that revision builds on. Several entries under "Changed" +are incompatible with 1.1.0 and ship in a minor release under the spec-conformance and security exceptions +described in [VERSIONING.md](VERSIONING.md). + +### Added + +- Handle the SEP-2575 modern request envelope in the server core (#475) +- Serve both lifecycle eras over stdio with an era lock per SEP-2575 (#478) +- Serve the sessionless modern path over Streamable HTTP per SEP-2575 (#479) +- Finalize `server/discover` and add client modern lifecycle support per SEP-2575 (#480) +- Let handlers return multi round-trip `input_required` results per SEP-2322 (#481) +- Add `MCP::Elicitation::EnumSchema` builders per SEP-1330 (#482) +- Stamp the required `resultType` on modern results per SEP-2322 (#487) +- Enforce the modern lifecycle admission rules per SEP-2575 (#489) +- Stream modern request notifications and honor the envelope `logLevel` per SEP-2575 (#490) +- Expose the user-defined `server_context` in instrumentation data (#493) +- Serve the `subscriptions/listen` notification stream per SEP-2575 (#495) +- Add opt-in `requestState` sealing via `MCP::Server::RequestStateSecurity` (#496) +- Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243 (#498) +- Stamp the required cache hints on modern cacheable results per SEP-2549 (#499) +- Drive multi round-trip `input_required` results on the client per SEP-2322 (#500) +- Fulfill `input_required` results on the legacy wire per SEP-2322 (#501) + +### Changed + +- Align modern envelope validation with the finalized specification (#491) +- Require the `Mcp-Method` header on the modern path (#492) +- Bound server-to-client requests with a timeout (#502) +- Refuse server-to-client requests in the modern lifecycle per SEP-2575 (#503) +- Bound the total wait across SSE reconnection attempts (#504) +- Bound automatic pagination in the MCP client (#505) +- Reject modern-removed methods before the connection era locks (#511) +- Stop negotiating modern protocol versions through the `initialize` handshake (#516) + +### Deprecated + +- Warn on modern client connects that declare the Roots or Sampling capabilities deprecated per SEP-2577 (#406, #516) + +### Fixed + +- Stop leaking exception messages to clients via JSON-RPC error data (#486) +- Return Invalid Params for unknown prompts and missing prompt arguments (#517) +- Restrict OAuth discovery to same-origin metadata URLs and refuse private-network destinations + ## [1.1.0] - 2026-08-01 ### Added diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 2d7cb5f..401d7fe 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.1.0" + VERSION = "1.2.0" end From 8a886639fd1f529aa584068e1a8eecd1aa8770e0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 15 Aug 2026 12:03:19 +0900 Subject: [PATCH 210/247] Refresh ROADMAP.md for the shipped 2026-07-28 support ## Motivation and Context The roadmap predates the 2026-07-28 implementation work and has drifted from the released SDK. The 2026-08-15 SEP-1730 tier audit flagged the drift while evaluating the roadmap requirement: - "Current Status" claims only 2025-06-18 and 2025-11-25 support, but the SDK now completes the 2026-07-28 revision and passes all scored server and client conformance scenarios for each revision's requirement set. - The SEP-2575 stateless lifecycle rewrite was described as reserved for 2.0, but it shipped during 1.x with both lifecycle eras served side by side; the few incompatible adjustments shipped in a minor release under the spec-conformance and security exceptions described in VERSIONING.md, as recorded in the 1.2.0 CHANGELOG entry. - Deprecation warnings for Roots/Sampling/Logging (SEP-2577) were described as future work but have shipped (#406, #516). - The documentation section undersold the coverage: all implemented non-experimental features are documented with examples (#472, #515). The refreshed text records the implemented spec revisions and how the 2026-07-28 revision actually shipped, and keeps the tasks extension (SEP-2663, #391) and the removal of legacy-era support as the remaining tracked items. Gem versions are deliberately not named: the roadmap describes the current state of the SDK, and the version-to-revision mapping already lives in CHANGELOG.md. ## How Has This Been Tested? Documentation-only change; no code paths are affected. Statements were cross-checked against the v1.2.0 CHANGELOG entry and the 2026-08-15 tier audit results (server 67/67 and client 50/50 scored conformance across the 2025-11-25 and 2026-07-28 requirement sets). ## Breaking Changes None. This only updates ROADMAP.md. --- ROADMAP.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6953b4a..8ff763d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,18 +5,22 @@ It is a living document and will evolve as the SDK matures. ## Current Status -The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios. +The SDK implements the 2025-06-18, 2025-11-25, and 2026-07-28 spec revisions, and passes all scored server +and client conformance scenarios for each revision's requirement set. +Optional protocol extensions (such as DPoP, workload identity federation, and the tasks extension) +are not yet implemented; SEP-1730 does not require extensions for any tier. ## API Stability -The 1.0.0 release will mark the public API as stable: breaking changes then ship only in major releases, +The 1.0.0 release marked the public API as stable: breaking changes ship only in major releases, apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md), which also defines the Semantic Versioning scheme and breaking-change policy. ## Deprecated Features The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577). -These features remain fully supported throughout 1.x, and deprecation warnings will be added in a future minor release. +These features remain fully supported throughout 1.x, and the SDK emits deprecation warnings when they are used +with protocol version 2026-07-28 or newer. Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: whether 2.0.0 removes them depends on how future MCP spec revisions treat these features and on adoption of their replacements. @@ -28,12 +32,16 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide ## Documentation -Reference documentation covers all core features. +Reference documentation covers all non-experimental features with examples. ## Tracking New Spec Revisions The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per release based on feature complexity. -Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests, -and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision, -such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0. +The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side, +so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`), +SEP-2322 multi round-trip requests, SEP-2549 cache hints, and SEP-2243 custom headers are available alongside +the legacy handshake. The few incompatible adjustments the revision required shipped in +a minor release under the spec-conformance and security exceptions described in [VERSIONING.md](VERSIONING.md). +Removing legacy-era support is reserved for a future major release. +The tasks extension (SEP-2663) remains planned ([#391](https://github.com/modelcontextprotocol/ruby-sdk/issues/391)). From 02db089038ff26381fc0e5ff434f6340c8f7a892 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 16 Aug 2026 03:18:32 +0900 Subject: [PATCH 211/247] Bound OAuth response bodies in the client ## Motivation and Context `MCP::Client::HTTP` caps a single message from the server at 4 MiB, but the OAuth flow talks to authorization endpoints over its own Faraday connection, and that one had no cap. Discovery, dynamic client registration, and token responses were buffered in full and handed to `JSON.parse`, so a server that answered an OAuth request with an endless body grew the client's memory for as long as it kept sending. `Flow` and `IDJAGTokenExchange` each build their own connection, so both needed the bound. The new `BoundedBody` counts bytes in a Faraday `on_data` callback and refuses the response once it passes `MAX_RESPONSE_BYTES`, which matches the 4 MiB of `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`. It hands back the status paired with the bounded body rather than the Faraday response, so no later caller can reach the unbounded `response.body`. Those bytes are counted after decompression. The default Net::HTTP adapter negotiates `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body that expands past the cap is refused partway through the expansion. That holds only while the connection leaves `Accept-Encoding` alone, which is now recorded in a comment on both `default_http_client` definitions. An over-cap response is refused rather than truncated: a partial discovery or token document cannot be validated. Protected resource metadata is the one place where refusing is not fatal, since any PRM discovery failure already selects the legacy authorization path by design. ## How Has This Been Tested? New unit tests in `test/mcp/client/oauth/bounded_body_test.rb` cover the streaming path, the at-limit boundary, and the fallback for adapters that ignore `on_data`. One of them serves a gzip body over a real socket rather than through WebMock, which returns a stubbed body verbatim and never runs the inflater; it fails if the cap ever starts measuring compressed bytes. New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/id_jag_token_exchange_test.rb` drive each of the four request wrappers end to end with an over-cap body; all five fail without the cap in place. ## Breaking Changes An OAuth endpoint response larger than 4 MiB is now rejected instead of parsed. --- README.md | 5 + lib/mcp/client/oauth.rb | 1 + lib/mcp/client/oauth/bounded_body.rb | 67 ++++++++ lib/mcp/client/oauth/flow.rb | 54 +++++-- lib/mcp/client/oauth/id_jag_token_exchange.rb | 13 +- test/mcp/client/oauth/bounded_body_test.rb | 150 ++++++++++++++++++ test/mcp/client/oauth/flow_test.rb | 63 ++++++++ .../oauth/id_jag_token_exchange_test.rb | 11 ++ 8 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 lib/mcp/client/oauth/bounded_body.rb create mode 100644 test/mcp/client/oauth/bounded_body_test.rb diff --git a/README.md b/README.md index 56effa4..9436145 100644 --- a/README.md +++ b/README.md @@ -3002,6 +3002,11 @@ the HTTP client connects to a moment later. The same-origin rule is what protect If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. +The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, +measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. +Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through +`http_client_factory:` is bounded as well, so there is no way to opt out of it. + #### Customizing the Faraday Connection You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index 6e10000..4fc615b 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "oauth/bounded_body" require_relative "oauth/discovery" require_relative "oauth/flow" require_relative "oauth/in_memory_storage" diff --git a/lib/mcp/client/oauth/bounded_body.rb b/lib/mcp/client/oauth/bounded_body.rb new file mode 100644 index 0000000..4aab2ad --- /dev/null +++ b/lib/mcp/client/oauth/bounded_body.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Bounds an OAuth response body while it arrives, rather than after it has been buffered. + # Discovery documents, registration responses, and token responses are all small by definition, + # so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of + # `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`. + class BoundedBody + MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + + # Raised while the body is read. Each caller translates it into its own error type, + # so this never reaches an embedder. + class TooLargeError < StandardError; end + + # What the OAuth code reads from a response. The Faraday response itself is not passed on, + # so a later caller cannot reach the unbounded `response.body` by accident. + Response = Struct.new(:status, :body) + + def initialize(max_bytes: MAX_RESPONSE_BYTES) + @max_bytes = max_bytes + @buffer = +"" + end + + # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates + # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body + # that expands past the cap is refused partway through the expansion rather than after it. That holds only while + # the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`. + def on_data + proc do |chunk, _received_bytes, _env| + @buffer << chunk + + raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes + end + end + + # The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver + # the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then, + # but refusing them still keeps an over-cap document out of `JSON.parse`. + def response_for(response) + Response.new(response.status, bounded_body(response)) + end + + private + + def bounded_body(response) + return @buffer unless @buffer.empty? + + body = response.body + body = body.is_a?(String) ? body : body.to_s + raise TooLargeError, too_large_message if body.bytesize > @max_bytes + + body + end + + def too_large_message + # Not "the authorization server": protected resource metadata comes from the MCP server's own origin, + # so this message covers endpoints on both sides of the flow. + "Response body from the OAuth endpoint exceeds #{@max_bytes} bytes" + end + end + + private_constant :BoundedBody + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 88413a3..68ccd9c 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -1021,26 +1021,54 @@ module MCP end def http_get(url) - http_client.get(url) + bounded_request do |on_data| + http_client.get(url) do |req| + req.options.on_data = on_data + end + end end def http_post_json(url, body) - http_client.post(url) do |req| - req.headers["Content-Type"] = "application/json" - req.headers["Accept"] = "application/json" - req.body = JSON.generate(body) + bounded_request do |on_data| + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/json" + req.headers["Accept"] = "application/json" + req.options.on_data = on_data + req.body = JSON.generate(body) + end end end def http_post_form(url, form, headers: {}) - http_client.post(url) do |req| - req.headers["Content-Type"] = "application/x-www-form-urlencoded" - req.headers["Accept"] = "application/json" - headers.each { |key, value| req.headers[key] = value } - req.body = URI.encode_www_form(form) + bounded_request do |on_data| + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + + headers.each do |key, value| + req.headers[key] = value + end + + req.options.on_data = on_data + req.body = URI.encode_www_form(form) + end end end + # Issues a request with the response body bounded as it arrives, and returns the status paired with + # that body. An over-cap response is refused rather than truncated: a partial discovery or token document + # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either, + # since the same server would serve the same body. + def bounded_request + bounded = BoundedBody.new + + response = yield(bounded.on_data) + + bounded.response_for(response) + rescue BoundedBody::TooLargeError => e + raise AuthorizationError, "#{e.message}." + end + def http_client @http_client ||= @http_client_factory.call end @@ -1050,8 +1078,14 @@ module MCP # that transparently followed a `3xx` would let a server reach a host the checks just refused. # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here # and the guards above only cover the first hop. + # + # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes + # the response only while the caller has not claimed that header; assigning it turns `decode_content` off, + # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it + # after the check. def default_http_client require "faraday" + Faraday.new do |faraday| faraday.headers["Accept"] = "application/json" end diff --git a/lib/mcp/client/oauth/id_jag_token_exchange.rb b/lib/mcp/client/oauth/id_jag_token_exchange.rb index 0e21140..57701ba 100644 --- a/lib/mcp/client/oauth/id_jag_token_exchange.rb +++ b/lib/mcp/client/oauth/id_jag_token_exchange.rb @@ -34,10 +34,13 @@ module MCP def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil) http_client ||= default_http_client + bounded = BoundedBody.new + response = begin - http_client.post(token_endpoint) do |req| + raw_response = http_client.post(token_endpoint) do |req| req.headers["Content-Type"] = "application/x-www-form-urlencoded" req.headers["Accept"] = "application/json" + req.options.on_data = bounded.on_data req.body = URI.encode_www_form( "grant_type" => GRANT_TYPE, "subject_token" => id_token, @@ -48,6 +51,10 @@ module MCP "client_id" => client_id, ) end + + bounded.response_for(raw_response) + rescue BoundedBody::TooLargeError => e + raise ExchangeError, "#{e.message}." rescue Faraday::Error => e raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}." end @@ -88,8 +95,12 @@ module MCP assertion end + # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`: + # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap + # onto compressed bytes. def default_http_client require "faraday" + Faraday.new do |faraday| faraday.headers["Accept"] = "application/json" end diff --git a/test/mcp/client/oauth/bounded_body_test.rb b/test/mcp/client/oauth/bounded_body_test.rb new file mode 100644 index 0000000..aeaf118 --- /dev/null +++ b/test/mcp/client/oauth/bounded_body_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "test_helper" +require "faraday" +require "socket" +require "stringio" +require "zlib" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class BoundedBodyTest < Minitest::Test + def test_on_data_rejects_chunks_that_together_exceed_the_limit + bounded = bounded_body(max_bytes: 64) + + error = assert_raises(too_large_error) do + 4.times do + bounded.on_data.call("a" * 32, 32, nil) + end + end + + assert_includes(error.message, "exceeds 64 bytes") + end + + def test_on_data_accepts_a_body_that_stops_at_the_limit + bounded = bounded_body(max_bytes: 64) + + 2.times do + bounded.on_data.call("a" * 32, 32, nil) + end + + assert_equal("a" * 64, bounded.response_for(stub_response(body: "")).body) + end + + def test_response_for_rejects_an_over_limit_body_when_the_adapter_did_not_stream + bounded = bounded_body(max_bytes: 64) + + error = assert_raises(too_large_error) do + bounded.response_for(stub_response(body: "a" * 65)) + end + + assert_includes(error.message, "exceeds 64 bytes") + end + + def test_response_for_falls_back_to_the_response_body_when_the_adapter_did_not_stream + bounded = bounded_body(max_bytes: 64) + + assert_equal("a" * 64, bounded.response_for(stub_response(body: "a" * 64)).body) + end + + def test_response_for_coerces_a_non_string_body + bounded = bounded_body(max_bytes: 64) + + assert_equal("", bounded.response_for(stub_response(body: nil)).body) + end + + def test_response_for_carries_the_status_through + bounded = bounded_body(max_bytes: 64) + + assert_equal(404, bounded.response_for(stub_response(status: 404, body: "")).status) + end + + # Served over a real socket rather than WebMock: WebMock hands back the stubbed body verbatim + # whatever its `Content-Encoding`, so the adapter's inflater never runs and a stubbed version + # of this test would assert the opposite of what it looks like. + def test_the_cap_counts_decompressed_bytes + bounded = bounded_body(max_bytes: 1024 * 1024) + compressed = gzip("a" * (5 * 1024 * 1024)) + + assert_operator(compressed.bytesize, :<, 1024 * 1024, "the compressed body must fit under the cap") + + serving_gzip(compressed) do |url| + assert_raises(too_large_error) do + Faraday.new.get(url) do |req| + req.options.on_data = bounded.on_data + req.options.timeout = 5 + end + end + end + end + + private + + def gzip(content) + io = StringIO.new + + writer = Zlib::GzipWriter.new(io) + writer.write(content) + writer.close + + io.string + end + + def serving_gzip(body) + # Other test files load `webmock/minitest`, which blocks real connections process-wide. + # This is the one place that needs the adapter's own request path. + WebMock.disable! if defined?(WebMock) + + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + + loop do + line = socket.gets + + break if line.nil? || line == "\r\n" + end + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Encoding: gzip\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n\r\n", + ) + socket.write(body) + + socket.close + rescue IOError, SystemCallError + nil + end + + # Scoped to start after both are assigned, so the cleanup below never runs against `nil` + # and a failure to open the socket surfaces as itself rather than as a `NoMethodError`. + begin + yield("http://127.0.0.1:#{server.addr[1]}/") + ensure + thread.kill + server.close + end + ensure + WebMock.enable! if defined?(WebMock) + end + + def bounded_body(max_bytes:) + OAuth.const_get(:BoundedBody).new(max_bytes: max_bytes) + end + + def too_large_error + OAuth.const_get(:BoundedBody)::TooLargeError + end + + def stub_response(body:, status: 200) + Struct.new(:status, :body).new(status, body) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 1cc248d..511e23f 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -948,6 +948,69 @@ module MCP assert_match(/authorization_servers/i, error.message) end + def test_run_refuses_an_authorization_server_metadata_body_over_the_cap + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + def test_run_refuses_a_protected_resource_metadata_body_over_the_cap + # PRM discovery failures select the legacy path by design, so the refusal shows up as + # the fallback rather than as a raise. The padded document is valid JSON naming + # an authorization server, so contacting that server is exactly what would happen + # if the body had been read: the assertion below fails if the cap stops working. + stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404) + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + padding: "a" * (4 * 1024 * 1024), + ), + ) + + assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_not_requested(:get, @as_metadata_url) + end + + def test_run_refuses_a_dynamic_client_registration_body_over_the_cap + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + def test_run_refuses_a_token_endpoint_body_over_the_cap + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + # One byte past the cap, so the three request wrappers are each proven to install the streaming callback. + # A wrapper that forgot it would buffer this whole body and parse it. + def over_cap_body + "a" * (4 * 1024 * 1024 + 1) + end + def test_run_raises_when_token_response_is_not_a_json_object # The token endpoint MUST return a JSON object per RFC 6749 §5.1. # A non-object body would otherwise be persisted into the provider diff --git a/test/mcp/client/oauth/id_jag_token_exchange_test.rb b/test/mcp/client/oauth/id_jag_token_exchange_test.rb index a5f6914..23d152c 100644 --- a/test/mcp/client/oauth/id_jag_token_exchange_test.rb +++ b/test/mcp/client/oauth/id_jag_token_exchange_test.rb @@ -101,6 +101,17 @@ module MCP error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } assert_match(/not a JSON object/, error.message) end + + def test_request_refuses_a_response_body_over_the_cap + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "a" * (4 * 1024 * 1024 + 1), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/exceeds \d+ bytes/, error.message) + end end end end From a53e8d9ed43d8f20d956e077821e1e4aa5ac012e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 15 Aug 2026 19:15:01 +0900 Subject: [PATCH 212/247] Reject duplicate in-flight JSON-RPC request IDs ## Motivation and Context A request id is the only key that routes request-scoped messages back to the request that caused them: progress and log notifications, server-to-client requests such as sampling and elicitation, and `notifications/cancelled` all resolve through it. Nothing checked that the id was free, so a second request arriving under an id already in flight took the routing entry over. From that point the first request's notifications were written to the second request's SSE stream, and whichever finished first removed the shared entry, leaving the survivor's messages nowhere to go. The spec puts the uniqueness obligation on the sender, and 2026-07-28 draws it at exactly the window this change enforces: "The request ID MUST NOT match the ID of any other request the sender has issued and not yet received a response for". Earlier revisions worded it as never reusing an id within the session. Neither says what a receiver does with a duplicate, but the Streamable HTTP rules require that messages the server sends before the response "SHOULD relate to the originating client request", and two live requests sharing one id make that impossible to honor for either of them. Answering the second one is the only option that stays correct, so it is refused the same way a duplicate `initialize` is, and for the same reason: a repeated id must not silently displace state the first one established. The reference SDKs currently accept the duplicate and let the newer request take the entry. That is not a settled design: the Python SDK carries a TODO naming rejection with `INVALID_REQUEST` as the revisit, so this moves toward where they expect to end up rather than away from them. `ServerSession#register_in_flight` now claims the id atomically and reports a collision instead of overwriting, which `Server` turns into an Invalid Request for every transport. `StreamableHTTPTransport` refuses the colliding POST with 409 before it registers a stream, since the stream is registered ahead of dispatch and would otherwise take over the routing entry for as long as the rejection takes. Removal is now guarded by identity everywhere the registry is keyed by request id, so a request that loses a race cannot retire a registration it does not own. `initialize` never registers, so its `ensure` no longer unregisters: a refused duplicate `initialize` reusing an in-flight id used to evict that registration on the way out, silently disabling cancellation for the request that owned it (Streamable HTTP refuses such a POST before dispatch; stdio reached this path). `drop_broken_stream` had a sharper form of the same bug: it removed whichever stream held the id while closing the one passed in, which are not necessarily the same stream. Only ids that are in flight are refused, which is the scope the current spec draws. Enforcing the older "never within the session" wording would instead mean remembering every id a session ever used, and the in-flight window is the part routing depends on either way. Sequential reuse keeps working, and neither this SDK's client (`SecureRandom.uuid`) nor a client built on the TypeScript SDK (a per-connection counter) can produce a collision. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` reproduce the collision: a tool parked mid-request, a second POST reusing its id, and assertions that the second POST is refused and that the first request still receives the progress frame it emits afterwards. Others cover the identity-guarded removal and confirm that an id can be reused once the earlier request has finished. New tests in `test/mcp/server_cancellation_test.rb` cover the registry directly, including the transport-independent Invalid Request and the survival of an in-flight registration across a refused duplicate `initialize` under the same id. All of them except the sequential-reuse guard fail without this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes A request whose id is already in flight on the same session is now answered with Invalid Request (HTTP 409 on Streamable HTTP) instead of being processed. A client that reuses ids concurrently was already violating the specification and was already having its notifications misrouted. --- lib/mcp/server.rb | 25 +++- .../transports/streamable_http_transport.rb | 39 ++++- lib/mcp/server_session.rb | 32 ++++- .../streamable_http_transport_test.rb | 134 ++++++++++++++++++ test/mcp/server_cancellation_test.rb | 69 +++++++++ 5 files changed, 287 insertions(+), 12 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index ee397ce..ea2a3c0 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -619,8 +619,24 @@ module MCP # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), # so do not track it in the in-flight registry. - cancellation = if related_request_id && method != Methods::INITIALIZE - session&.register_in_flight(related_request_id) + cancellation = nil + if related_request_id && method != Methods::INITIALIZE && session + cancellation = session.register_in_flight(related_request_id) + + # The spec puts the uniqueness obligation on the sender - "The request ID MUST NOT have been previously used by + # the requestor within the same session" - and says nothing about what a receiver does with a duplicate. + # Answering one is the only option that stays correct: the id routes request-scoped messages back to + # the request that caused them, and the transport's rule is that those messages "SHOULD relate to + # the originating client request", which a second live request under the same id makes impossible to honor + # for either of them. Refused the same way a duplicate `initialize` is, and for the same reason: + # so that a repeated id cannot silently displace state negotiated by the first one. + if cancellation.nil? + raise RequestHandlerError.new( + "Invalid Request: request id #{related_request_id.inspect} is already in flight", + request, + error_type: :invalid_request, + ) + end end ->(params) { @@ -727,7 +743,10 @@ module MCP reported_exception = wrapped raise wrapped ensure - session&.unregister_in_flight(related_request_id) if related_request_id + # `cancellation` is non-nil exactly when this request claimed the id above, so this also keeps `initialize` + # (which never registers) from evicting an in-flight registration under a reused id when the duplicate-`initialize` + # refusal raises out of the handler. + session&.unregister_in_flight(related_request_id, cancellation: cancellation) if related_request_id && cancellation end } end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index e420dc0..8d88977 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -450,8 +450,13 @@ module MCP @mutex.synchronize do session = @sessions[session_id] - if related_request_id && session&.dig(:post_request_streams, related_request_id) - session[:post_request_streams].delete(related_request_id) + if related_request_id + # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it, + # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped + # failure never reaches the session teardown below. + registered = session&.dig(:post_request_streams, related_request_id) + session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) + streams_to_close << stream else cleanup_and_collect_stream(session_id, streams_to_close) @@ -1595,6 +1600,14 @@ module MCP end end + # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below + # registers this request's stream under that id first, so without this check the colliding request + # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then + # clear the entry the original request still needs. + if related_request_id && server_session&.in_flight?(related_request_id) + return request_id_conflict_response + end + if session_id && !@stateless && !@enable_json_response handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) else @@ -1618,7 +1631,11 @@ module MCP session = @sessions[session_id] if session && related_request_id session[:post_request_streams] ||= {} - session[:post_request_streams][related_request_id] = stream + + # Claim the id only while it is free. `handle_regular_request` already refused the colliding request, + # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps + # its messages going where they belong. + session[:post_request_streams][related_request_id] ||= stream end end @@ -1630,7 +1647,11 @@ module MCP if related_request_id @mutex.synchronize do session = @sessions[session_id] - session[:post_request_streams]&.delete(related_request_id) if session + # Only retire our own registration: a request that never claimed the id, or one whose claim has + # already been replaced, must not unregister the stream that owns it. + registered = session&.dig(:post_request_streams, related_request_id) + + session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) end end @@ -1849,6 +1870,16 @@ module MCP ) end + # The POST counterpart of the GET conflict above. A request id already in flight cannot be given + # a stream of its own, because the id is what routes request-scoped messages back. + def request_id_conflict_response + json_rpc_error_response( + status: 409, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Conflict: Request id is already in flight for this session", + ) + end + def setup_sse_stream(session_id) body = create_sse_body(session_id) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index d12b18c..cfee2ab 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -58,19 +58,41 @@ module MCP @era = era end - # Registers a `Cancellation` token for an in-flight request. + # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight. + # The request id is the only key that routes request-scoped notifications, server-to-client requests, + # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id + # has no destination of its own. Rather than let the newcomer displace the registration, report the collision + # and leave the first request intact; the caller turns that into an Invalid Request. def register_in_flight(request_id) return if request_id.nil? cancellation = Cancellation.new(request_id: request_id) - @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation } - cancellation + registered = @in_flight_mutex.synchronize do + next false if @in_flight.key?(request_id) + + @in_flight[request_id] = cancellation + true + end + + registered ? cancellation : nil end - def unregister_in_flight(request_id) + # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes + # the entry only while it is still that one, so a request can never evict a registration it does not own. + def unregister_in_flight(request_id, cancellation: nil) return if request_id.nil? - @in_flight_mutex.synchronize { @in_flight.delete(request_id) } + @in_flight_mutex.synchronize do + next if cancellation && !@in_flight[request_id].equal?(cancellation) + + @in_flight.delete(request_id) + end + end + + # Whether `request_id` is currently in flight, so a transport can refuse a colliding request + # before registering any state of its own for it. + def in_flight?(request_id) + !lookup_in_flight(request_id).nil? end def lookup_in_flight(request_id) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 26b8906..2cc42de 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -3431,6 +3431,110 @@ module MCP assert_equal "ok", result[:content][:text] end + test "refuses a POST reusing an in-flight request id and keeps the original stream" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + gate = Queue.new + server.define_tool(name: "victim_tool") do |server_context:| + server_context.report_progress(1, message: "first-frame") + gate.pop + server_context.report_progress(2, message: "second-frame") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, server_session = start_session(transport) + + victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) + victim_stream = TestStream.new + victim_thread = Thread.new { victim[2].call(victim_stream) } + sleep(0.01) until server_session.lookup_in_flight("req-1") + + attacker = transport.handle_request(colliding_tool_call(session_id, "req-1")) + + assert_equal 409, attacker[0] + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + JSON.parse(attacker[2][0]).dig("error", "code"), + ) + + gate << :go + victim_thread.join + + # The registration survived the refusal, so the frame emitted after it still reaches + # the request that asked for it. + assert_includes victim_stream.string, "first-frame" + assert_includes victim_stream.string, "second-frame" + end + + test "a finishing request leaves a stream another request registered under the same id" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + gate = Queue.new + server.define_tool(name: "victim_tool") do |server_context:| + gate.pop + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, server_session = start_session(transport) + + victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) + victim_stream = TestStream.new + victim_thread = Thread.new { victim[2].call(victim_stream) } + sleep(0.01) until server_session.lookup_in_flight("req-1") + + # Stand in for a stream that won the registration in a race the pre-dispatch refusal normally prevents. + # Finishing the other request must not unregister it. + foreign_stream = TestStream.new + sessions = transport.instance_variable_get(:@sessions) + sessions[session_id][:post_request_streams]["req-1"] = foreign_stream + + gate << :go + victim_thread.join + + assert_same foreign_stream, sessions[session_id][:post_request_streams]["req-1"] + end + + test "a broken request-scoped stream drops only itself, even when it is not the registered one" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + session_id, = start_session(transport) + sessions = transport.instance_variable_get(:@sessions) + registered = TestStream.new + sessions[session_id][:post_request_streams] = { "req-1" => registered } + + transport.send(:drop_broken_stream, session_id, TestStream.new, "req-1") + + assert sessions.key?(session_id), "a request-scoped failure must not tear down the session" + assert_same registered, sessions[session_id][:post_request_streams]["req-1"] + end + + test "allows a request id to be reused once the earlier request has finished" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "victim_tool") do |server_context:| + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, = start_session(transport) + + 2.times do + response = transport.handle_request(colliding_tool_call(session_id, "req-1")) + + assert_equal 200, response[0] + stream = TestStream.new + response[2].call(stream) + assert_includes stream.string, "done" + end + end + test "JSON response mode returns accepted when cancellation suppresses response" do server = Server.new(name: "test", tools: [], prompts: [], resources: []) transport = StreamableHTTPTransport.new(server, enable_json_response: true) @@ -6199,6 +6303,36 @@ module MCP writes end + # Initializes `transport` and returns its session id together with the `ServerSession`, + # which the in-flight request id tests poll to know when a request has really started. + def start_session(transport) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(request)[1]["mcp-session-id"] + + [session_id, transport.instance_variable_get(:@sessions)[session_id][:server_session]] + end + + # A `tools/call` for `victim_tool` under a caller-chosen request id, with a progress token so + # the tool's `report_progress` has somewhere to go. + def colliding_tool_call(session_id, request_id) + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { + jsonrpc: "2.0", + id: request_id, + method: "tools/call", + params: { name: "victim_tool", arguments: {}, _meta: { progressToken: "tok" } }, + }.to_json, + ) + end + def create_rack_request(method, path, headers, body = nil) default_accept = case method when "POST" diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index a91bf1a..9fc28cf 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -5,6 +5,7 @@ require "test_helper" module MCP class ServerCancellationTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper class MockTransport < Transport attr_reader :requests, :notifications, :cancelled_request_ids @@ -333,6 +334,74 @@ module MCP assert_includes @mock_transport.cancelled_request_ids, "req-9" end + test "register_in_flight refuses an id that is already in flight" do + first = @session.register_in_flight("req-1") + + assert first + assert_nil @session.register_in_flight("req-1"), "a second live request cannot share the id" + assert_same first, @session.lookup_in_flight("req-1"), "the first registration must survive" + end + + test "unregister_in_flight leaves a registration it does not own" do + owner = @session.register_in_flight("req-1") + @session.unregister_in_flight("req-1", cancellation: Cancellation.new(request_id: "req-1")) + + assert_same owner, @session.lookup_in_flight("req-1") + + @session.unregister_in_flight("req-1", cancellation: owner) + + assert_nil @session.lookup_in_flight("req-1") + end + + test "in_flight? reports whether an id is registered" do + refute @session.in_flight?("req-1") + + @session.register_in_flight("req-1") + + assert @session.in_flight?("req-1") + end + + test "a request reusing an in-flight id is answered with Invalid Request" do + @server.define_tool(name: "slow") do |server_context:| + sleep(0.2) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request = { + jsonrpc: "2.0", + id: "req-1", + method: Methods::TOOLS_CALL, + params: { name: "slow", arguments: {} }, + } + + in_flight = Thread.new { @session.handle(request) } + sleep(0.01) until @session.lookup_in_flight("req-1") + + duplicate = @session.handle(request) + + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + duplicate.dig(:error, :code) || duplicate.dig("error", "code"), + ) + in_flight.join + end + + test "a refused duplicate initialize leaves an in-flight registration under its reused id" do + @session.handle(jsonrpc: "2.0", id: "init", method: Methods::INITIALIZE, params: initialize_params) + + owner = @session.register_in_flight("req-1") + + # `initialize` bypasses the duplicate-id refusal (it is never in flight itself), so its + # rejection path is the one place a reused id reaches a handler while the id is still live. + duplicate = @session.handle(jsonrpc: "2.0", id: "req-1", method: Methods::INITIALIZE, params: initialize_params) + + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + duplicate.dig(:error, :code) || duplicate.dig("error", "code"), + ) + assert_same owner, @session.lookup_in_flight("req-1"), "the registration must survive the refused initialize" + end + test "parent cancellation propagates to nested server-to-client requests" do @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) From 77f571bc5d9eb1c57533e547def24ff9b0023a75 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 17 Aug 2026 03:08:18 +0900 Subject: [PATCH 213/247] [Doc] Describe the client's automatic MRTR resumption The Multi-Round-Trip Results section still said "automatic resumption is not implemented yet", but the client driver shipped in #500: with a handler registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, and `read_resource` methods fulfill embedded `inputRequests` and re-issue the original request automatically, raising `MCP::Client::InputRequiredError` only when no matching handler exists. Rewrite the paragraph to describe the implemented behavior, including the `input_required_max_rounds` cap and the keywords for manual driving. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 56effa4..87901d0 100644 --- a/README.md +++ b/README.md @@ -3028,9 +3028,11 @@ The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. -The Ruby client recognizes such results and raises `MCP::Client::InputRequiredError` instead of returning them as if they were final. The error exposes `input_requests`, `request_state`, -and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` -are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. +The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, +and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` +(10 by default, matching the TypeScript and Python SDKs). Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; +the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords. +`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 server returns. The server stamps `resultType: "complete"` on all results of requests carrying the modern `_meta` envelope (and on `server/discover` results), while results that already carry a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. From 88831b46f62254fccc1ea62d8b5e0ad1aa55ab55 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 21 Aug 2026 03:03:27 +0900 Subject: [PATCH 214/247] Move the Documentation From README.md to the Documentation Site Move the documentation that had grown to 3000+ lines in README.md onto https://ruby.sdk.modelcontextprotocol.io and slim the README down to a quick start and feature overview, following the Python SDK layout: - Add 21 server pages as the docs/_server/ collection (overview plus transports, discovery, tools, prompts, resources, roots, sampling, elicitation, multi round-trip results, notifications, notification subscriptions, cancellation, progress, ping, completions, logging, pagination, server context, configuration, and custom methods), 8 client pages as the docs/_client/ collection (overview, transports, lifecycle, multi round-trip results, cancellation, ping, pagination, and authorization), and 3 extension pages as the docs/_extensions/ collection (overview, capability extensions, and MCP Apps, following the Extensions Overview recommendation that SDK documentation list the supported extensions) - one page per topic, ordered to match the sidebar of the 2026-07-28 specification, with the client-side APIs (pinging, cancelling, and paginating from MCP::Client) documented under Building Clients - Add top-level Examples and Protocol Versions pages after Installation, linking the runnable examples in examples/ and summarizing the supported protocol versions, the era model, and client negotiation - Render the three sections as just-the-docs collections, which list every page beneath a plain category heading in the sidebar with no folding, and serve every page at an extensionless URL under /server/, /client/, and /extensions/, matching the sibling SDK documentation sites - Replace docs/building-servers.md and docs/building-clients.md with redirects to the new section overview pages via jekyll-redirect-from, redirect the previously published /installation.html to its extensionless URL, fold their content that was missing from README.md into the new pages, and update the Tool argument keys reference comment in lib/mcp/server.rb to the relocated Tools page - Reduce README.md to the badges, a compact feature overview in the Python SDK style, installation instructions, a stdio server and client quick start, and a License section, using absolute URLs for the remaining repository links so they resolve on rubygems.org - Adapt formatting where GitHub rendering habits break on the site: convert the numbered "three ways to define" lists into headings, since kramdown restarts numbering at 1 when code blocks split list items, convert GitHub-style alerts into just-the-docs callouts with the SEP-2260 server-to-client association note raised to a red warning, merge the duplicated Exception Reporting and Configuration Block Data sections into the Configuration page, and relocate the Streamable HTTP settings that were nested under the Logging section into the Transports page - Refresh the migrated content against the current implementation: correct stale claims and broken samples the README carried, note on each session-era feature how it relates to the modern lifecycle of MCP 2026-07-28, and point spec links at the latest revision, keeping deliberate 2025-11-25 pins for pages the modern revision removed - Style the site after the Rails API documentation palette (red links and accents on neutral surfaces) with matching light and dark color schemes, center the sidebar and content as one block, add a Previous/Next footer pager following the sidebar order, open external links in a new tab, and serve the MCP logo as the favicon Every code block and heading from the previous README was verified to have a home in the new docs pages or the slimmed README before the reduction; a few samples were corrected rather than copied, as noted above. --- AGENTS.md | 9 + README.md | 3012 +------------------- docs/_client/authorization.md | 260 ++ docs/_client/cancellation.md | 71 + docs/_client/index.md | 46 + docs/_client/lifecycle.md | 78 + docs/_client/multi-round-trip-results.md | 60 + docs/_client/pagination.md | 80 + docs/_client/ping.md | 35 + docs/_client/transports.md | 261 ++ docs/_config.yml | 46 +- docs/_extensions/capability-extensions.md | 35 + docs/_extensions/index.md | 17 + docs/_extensions/mcp-apps.md | 48 + docs/_includes/footer_custom.html | 48 + docs/_includes/head_custom.html | 23 +- docs/_sass/color_schemes/ruby-dark.scss | 17 + docs/_sass/color_schemes/ruby-light.scss | 8 + docs/_sass/custom/custom.scss | 76 +- docs/_sass/custom/setup.scss | 2 + docs/_server/cancellation.md | 137 + docs/_server/completions.md | 52 + docs/_server/configuration.md | 206 ++ docs/_server/custom-methods.md | 62 + docs/_server/discovery.md | 61 + docs/_server/elicitation.md | 231 ++ docs/_server/index.md | 53 + docs/_server/logging.md | 90 + docs/_server/multi-round-trip-results.md | 85 + docs/_server/notification-subscriptions.md | 71 + docs/_server/notifications.md | 68 + docs/_server/pagination.md | 78 + docs/_server/ping.md | 50 + docs/_server/progress.md | 64 + docs/_server/prompts.md | 194 ++ docs/_server/resources.md | 319 +++ docs/_server/roots.md | 82 + docs/_server/sampling.md | 87 + docs/_server/server-context.md | 111 + docs/_server/tools.md | 419 +++ docs/_server/transports.md | 274 ++ docs/assets/css/just-the-docs-dark.scss | 2 +- docs/assets/css/just-the-docs-light.scss | 2 +- docs/building-clients.md | 153 - docs/building-servers.md | 332 --- docs/examples.md | 48 + docs/favicon.svg | 11 + docs/index.md | 76 +- docs/installation.md | 17 +- docs/protocol-versions.md | 62 + lib/mcp/server.rb | 2 +- 51 files changed, 4234 insertions(+), 3497 deletions(-) create mode 100644 docs/_client/authorization.md create mode 100644 docs/_client/cancellation.md create mode 100644 docs/_client/index.md create mode 100644 docs/_client/lifecycle.md create mode 100644 docs/_client/multi-round-trip-results.md create mode 100644 docs/_client/pagination.md create mode 100644 docs/_client/ping.md create mode 100644 docs/_client/transports.md create mode 100644 docs/_extensions/capability-extensions.md create mode 100644 docs/_extensions/index.md create mode 100644 docs/_extensions/mcp-apps.md create mode 100644 docs/_includes/footer_custom.html create mode 100644 docs/_sass/color_schemes/ruby-dark.scss create mode 100644 docs/_sass/color_schemes/ruby-light.scss create mode 100644 docs/_sass/custom/setup.scss create mode 100644 docs/_server/cancellation.md create mode 100644 docs/_server/completions.md create mode 100644 docs/_server/configuration.md create mode 100644 docs/_server/custom-methods.md create mode 100644 docs/_server/discovery.md create mode 100644 docs/_server/elicitation.md create mode 100644 docs/_server/index.md create mode 100644 docs/_server/logging.md create mode 100644 docs/_server/multi-round-trip-results.md create mode 100644 docs/_server/notification-subscriptions.md create mode 100644 docs/_server/notifications.md create mode 100644 docs/_server/pagination.md create mode 100644 docs/_server/ping.md create mode 100644 docs/_server/progress.md create mode 100644 docs/_server/prompts.md create mode 100644 docs/_server/resources.md create mode 100644 docs/_server/roots.md create mode 100644 docs/_server/sampling.md create mode 100644 docs/_server/server-context.md create mode 100644 docs/_server/tools.md create mode 100644 docs/_server/transports.md delete mode 100644 docs/building-clients.md delete mode 100644 docs/building-servers.md create mode 100644 docs/examples.md create mode 100644 docs/favicon.svg create mode 100644 docs/protocol-versions.md diff --git a/AGENTS.md b/AGENTS.md index 5f0ee62..8af6c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - `rake test` - Run all tests - `rake rubocop` - Run linter - `rake` - Run tests and linting (default task) +- `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem @@ -34,6 +35,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Keep dependencies minimal - Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only +## Documentation + +- User-facing documentation lives in `docs/`, one page per topic, published at https://ruby.sdk.modelcontextprotocol.io (deploys only when a release is published) +- Pages live in the `docs/_server/`, `docs/_client/`, and `docs/_extensions/` collections +- Keep README.md slim: quick start and pointers only; document features on the relevant docs page +- Internal links are absolute and extensionless (e.g. `/server/tools/`); front matter is followed by a blank line before the h1 +- Callout tiers: `.note` (supplementary), `.important` (spec constraints), `.warning` (deprecated features) + ## Commit message conventions - Use conventional commit format when possible diff --git a/README.md b/README.md index 964c26f..45823ab 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,21 @@ The official Ruby SDK for Model Context Protocol servers and clients. +Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io. + +## Features + +- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination + ## Installation Add this line to your application's Gemfile: ```ruby -gem 'mcp' +gem "mcp" ``` And then execute: @@ -24,81 +33,14 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. -## Building an MCP Server +## Quick Start -The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. -It implements the Model Context Protocol specification, handling model context requests and responses. +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. -### Key Features +### MCP Server -- Implements JSON-RPC 2.0 message handling -- Supports protocol initialization and capability negotiation -- Manages tool registration and invocation -- Supports prompt registration and execution -- Supports resource registration and retrieval -- Supports stdio & Streamable HTTP (including SSE) transports -- Supports notifications for list changes (tools, prompts, resources) -- Supports roots (server-to-client filesystem boundary queries) -- Supports sampling (server-to-client LLM completion requests) -- Supports cursor-based pagination for list operations -- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) - -### Supported Methods - -- `initialize` - Initializes the protocol and returns server capabilities -- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern `supportedVersions`, - `capabilities`, `instructions`, the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional - `io.modelcontextprotocol/serverInfo` stamp in the result `_meta`, and responds before `initialize` - and without an `Mcp-Session-Id`. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope - (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, - and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates - the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips - the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result -- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET listening stream: - the client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), - the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, - and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. Served on the Streamable HTTP modern path; - stdio answers `-32601`. Concurrent streams are capped by `max_listen_subscriptions:` (default 1000), and each stream receives an SSE keepalive - comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` - when an upstream proxy already keeps the stream alive -- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that - opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for - additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request, - which the modern lifecycle forbids. On the retried request the handler re-runs from the start and reads the answers via - `server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` - (deterministic replay; the server holds no memory between rounds). The SDK rejects issuance on legacy requests and returns `-32021` - when an embedded request needs a client capability the request did not declare. The echoed `requestState` arrives as - client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to - have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers. - Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as - the handler wrote it and protecting it is the handler author's responsibility. On the client, register handlers with - `on_elicitation` / `on_sampling` / `on_roots` - the same registrations that answer a real server-to-client request - and - declare the matching capabilities on `connect` (a server embeds only the request kinds the client declared); - `call_tool` / `get_prompt` / `read_resource` then resume `input_required` results automatically: each embedded request is fulfilled by - the matching handler and the original request is re-issued with `inputResponses` plus the echoed `requestState` - (with exponential backoff for `requestState`-only load-shedding legs). Without a matching handler they raise `MCP::Client::InputRequiredError`, - and the `input_responses:` / `request_state:` keyword arguments support manual driving -- `ping` - Simple health check -- `logging/setLevel` - Configures the minimum log level for the server -- `tools/list` - Lists all registered tools and their schemas -- `tools/call` - Invokes a specific tool with provided arguments -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves a specific prompt by name -- `resources/list` - Lists all registered resources and their schemas -- `resources/read` - Retrieves a specific resource by name -- `resources/templates/list` - Lists all registered resource templates and their schemas -- `resources/subscribe` - Subscribes to updates for a specific resource -- `resources/unsubscribe` - Unsubscribes from updates for a specific resource -- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs -- `roots/list` - Requests filesystem roots from the client (server-to-client) -- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) -- `elicitation/create` - Requests user input from the client (server-to-client) - -### Usage - -#### Stdio Transport - -If you want to build a local command-line application, you can use the stdio transport: +A minimal server defines a tool and serves it over the stdio transport: ```ruby require "mcp" @@ -134,2512 +76,21 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` -`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). - -You can run this script and then type in requests to the server at the command line. +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: ```console -$ ruby examples/stdio_server.rb +$ ruby server.rb {"jsonrpc":"2.0","id":"1","method":"ping"} {"jsonrpc":"2.0","id":"2","method":"tools/list"} {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -#### Streamable HTTP Transport +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](https://ruby.sdk.modelcontextprotocol.io/server/transports/). -`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. -The following examples show two common integration styles in Rails. +### MCP Client -> [!IMPORTANT] -> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> -> When running multiple server instances behind a load balancer, configure your load balancer to use -> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always -> routed to the same instance. -> -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - -> [!IMPORTANT] -> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to -> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. -> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, -> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` -> header are unaffected. -> -> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: -> -> ```ruby -> transport = MCP::Server::Transports::StreamableHTTPTransport.new( -> server, -> allowed_hosts: ["mcp.example.com"], -> allowed_origins: ["https://app.example.com"], -> ) -> ``` -> -> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, -> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` -> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). - -##### Rails (mount) - -`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: - -```ruby -# config/routes.rb -server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], -) -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -Rails.application.routes.draw do - mount transport => "/mcp" -end -``` - -`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches -`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), -`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per -the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), -so no additional route configuration is needed. - -A complete runnable application using this approach is available in [`examples/rails`](examples/rails). - -##### Rails (controller) - -While the mount approach creates a single server at boot time, the controller approach creates a new server per request. -This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). - -`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): - -```ruby -class McpController < ActionController::API - def create - server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, - ) - # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set. - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) - status, headers, body = transport.handle_request(request) - - render(json: body.first, status: status, headers: headers) - end -end -``` - -### Configuration - -The gem can be configured using the `MCP.configure` block: - -```ruby -MCP.configure do |config| - config.exception_reporter = ->(exception, server_context) { - # Your exception reporting logic here - # For example with Bugsnag: - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end - } - - config.around_request = ->(data, &request_handler) { - logger.info("Start: #{data[:method]}") - request_handler.call - logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") - } -end -``` - -or by creating an explicit configuration and passing it into the server. -This is useful for systems where an application hosts more than one MCP server but -they might require different configurations. - -```ruby -configuration = MCP::Configuration.new -configuration.exception_reporter = ->(exception, server_context) { - # Your exception reporting logic here - # For example with Bugsnag: - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end -} - -configuration.around_request = ->(data, &request_handler) { - logger.info("Start: #{data[:method]}") - request_handler.call - logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") -} - -server = MCP::Server.new( - # ... all other options - configuration:, -) -``` - -### Capability Extensions - -Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. -Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); -values are extension-defined configuration objects, with `{}` meaning "supported with no settings". - -On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: - -```ruby -capabilities = MCP::Server::Capabilities.new -capabilities.support_tools -capabilities.support_extensions("com.example/feature" => { enabled: true }) - -server = MCP::Server.new(name: "my_server", capabilities: capabilities) -``` - -The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are -readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports). - -On the client, pass extensions through `connect`: - -```ruby -client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) -``` - -### MCP Apps (SEP-1865) - -MCP Apps is a Final extension (negotiated via the Capability Extensions mechanism above) that lets a server ship interactive -HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention, -and `MCP::Apps` provides the vocabulary and helpers: - -```ruby -capabilities = MCP::Server::Capabilities.new -capabilities.support_tools -capabilities.support_resources -capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } } - -server = MCP::Server.new( - name: "weather_server", - capabilities: capabilities, - # UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type. - resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], -) - -server.resources_read_handler do |params| - [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] -end - -# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also -# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec). -server.define_tool( - name: "get_weather", - meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), -) do |server_context:| - # The extension is optional: always return a meaningful text result, and use - # `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content. - MCP::Apps.client_supports?(server.client_capabilities) # => true when the host declared the extension - MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) -end -``` - -Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions) -is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. -See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). - -### Server Context and Configuration Block Data - -#### `server_context` - -The `server_context` is a user-defined hash that is passed into the server instance and made available to tool and prompt calls. -It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. - -**Type:** - -```ruby -server_context: { [String, Symbol] => Any } -``` - -**Example:** - -```ruby -server = MCP::Server.new( - name: "my_server", - server_context: { user_id: current_user.id, request_id: request.uuid } -) -``` - -This hash is then passed as the `server_context` keyword argument to tool and prompt calls. -Note that the exception reporter does not receive this user-defined hash, and instrumentation -callbacks omit it unless you opt in with `instrument_server_context`. -See the relevant sections below for the arguments they receive. - -#### Request-specific `_meta` Parameter - -The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. - -> [!NOTE] -> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). -> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it -> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. - -**Access Pattern:** - -When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: - -```ruby -class MyTool < MCP::Tool - def self.call(message:, server_context:) - # Access provider-specific metadata - session_id = server_context.dig(:_meta, :session_id) - request_id = server_context.dig(:_meta, :request_id) - - # Access server's original context - user_id = server_context.dig(:user_id) - - MCP::Tool::Response.new([{ - type: "text", - text: "Processing for user #{user_id} in session #{session_id}" - }]) - end -end -``` - -**Client Request Example:** - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "my_tool", - "arguments": { "message": "Hello" }, - "_meta": { - "session_id": "abc123", - "request_id": "req_456" - } - } -} -``` - -**Distributed Tracing (W3C Trace Context):** - -Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating -[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through -incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`, -`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values -to your tracing system yourself: - -```ruby -class TracedTool < MCP::Tool - def self.call(message:, server_context:) - traceparent = server_context.dig(:_meta, :traceparent) - # Hand traceparent/tracestate/baggage to your tracing library - # (e.g. the opentelemetry-ruby gems) to continue the caller's trace. - - MCP::Tool::Response.new([{ type: "text", text: "ok" }]) - end -end -``` - -On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) -accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request: - -```ruby -meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } - -client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta) -client.read_resource(uri: "file:///report.txt", meta: meta) -``` - -#### Configuration Block Data - -##### Exception Reporter - -The exception reporter receives: - -- `exception`: The Ruby exception object that was raised -- `server_context`: A hash describing where the failure occurred (e.g., `{ request: }` - for request handling, `{ notification: "tools_list_changed" }` for notification delivery). - This is not the user-defined `server_context` passed to `Server.new`. - -**Signature:** - -```ruby -exception_reporter = ->(exception, server_context) { ... } -``` - -##### Around Request - -The `around_request` hook wraps request handling, allowing you to execute code before and after each request. -This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. - -The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: - -**Signature:** - -```ruby -around_request = ->(data, &request_handler) { request_handler.call } -``` - -**`data` availability by timing:** - -- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled -- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` -- Not available inside `around_request`: `duration` (added after `around_request` returns) - -**Exposing the user-defined `server_context` (opt in):** - -`data` omits the user-defined `server_context` by default, because that hash is -application-supplied and may hold values a tracing backend should not receive. -Enable it when you need to tag spans with the request's subject: - -```ruby -MCP.configure do |config| - config.instrument_server_context = true - - config.around_request = ->(data, &request_handler) { - Sentry.set_user(id: data.dig(:server_context, :user_id)) - request_handler.call - } -end -``` - -`data[:server_context]` is the hash passed to `Server.new` — `nil` when the host -set none. It is not the exception reporter's context argument, which describes -where a failure occurred rather than who made the request. - -> [!NOTE] -> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods -> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled -> (for example, `prompt_name` is not recorded when the prompt is not found). -> `duration` is added after `around_request` returns, so it is not visible from within the hook. - -**Example:** - -```ruby -MCP.configure do |config| - config.around_request = ->(data, &request_handler) { - logger.info("Start: #{data[:method]}") - request_handler.call - logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") - } -end -``` - -##### Instrumentation Callback (soft-deprecated) - -> [!NOTE] -> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. -> -> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: -> -> ```ruby -> # Before -> config.instrumentation_callback = ->(data) { log(data) } -> -> # After -> config.around_request = ->(data, &request_handler) do -> request_handler.call -> ensure -> log(data) -> end -> ``` -> -> Note that `data[:duration]` is not available inside `around_request`. -> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. - -The instrumentation callback is called after each request finishes, whether successfully or with an error. -It receives a hash with the following possible keys: - -- `method`: (String) The protocol method called (e.g., "ping", "tools/list") -- `tool_name`: (String, optional) The name of the tool called -- `tool_arguments`: (Hash, optional) The arguments passed to the tool -- `prompt_name`: (String, optional) The name of the prompt called -- `resource_uri`: (String, optional) The URI of the resource called -- `error`: (String, optional) Error code if a lookup failed -- `duration`: (Float) Duration of the call in seconds -- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request -- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when - `instrument_server_context` is enabled - -**Signature:** - -```ruby -instrumentation_callback = ->(data) { ... } -``` - -### Server Protocol Version - -The server's protocol version can be overridden using the `protocol_version` keyword argument: - -```ruby -configuration = MCP::Configuration.new(protocol_version: "2024-11-05") -MCP::Server.new(name: "test_server", configuration: configuration) -``` - -If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default. - -This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: - -```ruby -MCP::Configuration.new(protocol_version: nil) -``` - -If an invalid `protocol_version` value is set, an `ArgumentError` is raised. - -The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model, -`2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`; -a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs. -Clients reach `2026-07-28` through `server/discover` and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. - -Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. - -### Exception Reporting - -The exception reporter receives two arguments: - -- `exception`: The Ruby exception object that was raised -- `server_context`: A hash containing contextual information about where the error occurred - -The `server_context` hash includes: - -- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) -- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) - -When an exception occurs: - -1. The exception is reported via the configured reporter -2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }` -3. For other requests, the exception is re-raised after reporting - -If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. - -### Tools - -MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. - -This gem provides a `MCP::Tool` class that can be used to create tools in three ways: - -1. As a class definition: - -```ruby -class MyTool < MCP::Tool - title "My Tool" - description "This tool performs specific functionality..." - input_schema( - properties: { - message: { type: "string" }, - }, - required: ["message"] - ) - output_schema( - properties: { - result: { type: "string" }, - success: { type: "boolean" }, - timestamp: { type: "string", format: "date-time" } - }, - required: ["result", "success", "timestamp"] - ) - annotations( - read_only_hint: true, - destructive_hint: false, - idempotent_hint: true, - open_world_hint: false, - title: "My Tool" - ) - - def self.call(message:, server_context:) - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) - end -end - -tool = MyTool -``` - -2. By using the `MCP::Tool.define` method with a block: - -```ruby -tool = MCP::Tool.define( - name: "my_tool", - title: "My Tool", - description: "This tool performs specific functionality...", - annotations: { - read_only_hint: true, - title: "My Tool" - } -) do |args, server_context:| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -3. By using the `MCP::Server#define_tool` method with a block: - -```ruby -server = MCP::Server.new -server.define_tool( - name: "my_tool", - description: "This tool performs specific functionality...", - annotations: { - title: "My Tool", - read_only_hint: true - } -) do |args, server_context:| - Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -The server_context parameter is the server_context passed into the server and can be used to pass per request information, -e.g. around authentication state. - -Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`. -Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`). -See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip. - -### Tool Annotations - -Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: - -- `destructive_hint`: Indicates if the tool performs destructive operations. Defaults to true -- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false -- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true -- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false -- `title`: A human-readable title for the tool - -Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. - -> [!NOTE] -> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`. - -### Tool Output Schemas - -Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways: - -1. **Class definition with output_schema:** - -```ruby -class WeatherTool < MCP::Tool - tool_name "get_weather" - description "Get current weather for a location" - - input_schema( - properties: { - location: { type: "string" }, - units: { type: "string", enum: ["celsius", "fahrenheit"] } - }, - required: ["location"] - ) - - output_schema( - properties: { - temperature: { type: "number" }, - condition: { type: "string" }, - humidity: { type: "integer" } - }, - required: ["temperature", "condition", "humidity"] - ) - - def self.call(location:, units: "celsius", server_context:) - # Call weather API and structure the response - api_response = WeatherAPI.fetch(location, units) - weather_data = { - temperature: api_response.temp, - condition: api_response.description, - humidity: api_response.humidity_percent - } - - output_schema.validate_result(weather_data) - - MCP::Tool::Response.new([{ - type: "text", - text: weather_data.to_json - }]) - end -end -``` - -2. **Using Tool.define with output_schema:** - -```ruby -tool = MCP::Tool.define( - name: "calculate_stats", - description: "Calculate statistics for a dataset", - input_schema: { - properties: { - numbers: { type: "array", items: { type: "number" } } - }, - required: ["numbers"] - }, - output_schema: { - properties: { - mean: { type: "number" }, - median: { type: "number" }, - count: { type: "integer" } - }, - required: ["mean", "median", "count"] - } -) do |args, server_context:| - # Calculate statistics and validate against schema - MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }]) -end -``` - -3. **Using OutputSchema objects:** - -```ruby -class DataTool < MCP::Tool - output_schema MCP::Tool::OutputSchema.new( - properties: { - success: { type: "boolean" }, - data: { type: "object" } - }, - required: ["success"] - ) -end -``` - -Output schema may also describe an array of objects: - -```ruby -class WeatherTool < MCP::Tool - output_schema( - type: "array", - items: { - properties: { - temperature: { type: "number" }, - condition: { type: "string" }, - humidity: { type: "integer" } - }, - required: ["temperature", "condition", "humidity"] - } - ) -end -``` - -Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`, -applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`). - -Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root -(`{ type: "string" }`) or a root-level composition: - -```ruby -class FlexibleTool < MCP::Tool - output_schema( - oneOf: [ - { type: "string" }, - { type: "array", items: { type: "number" } } - ] - ) -end -``` - -Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it -(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to -all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are -capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects; -violations raise `ArgumentError` at construction time. - -MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: - -- **Server Validation**: Servers MUST provide structured results that conform to the output schema -- **Client Validation**: Clients SHOULD validate structured results against the output schema -- **Better Integration**: Enables strict schema validation, type information, and improved developer experience -- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block - -The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. - -By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. To validate successful tool responses, enable `validate_tool_call_results`: - -```ruby -configuration = MCP::Configuration.new(validate_tool_call_results: true) -server = MCP::Server.new( - name: "example_server", - tools: [WeatherTool], - configuration: configuration -) -``` - -When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. Error responses are not validated against the output schema. - -### Tool Responses with Structured Content - -Tools can return structured data alongside text content using the `structured_content` parameter. - -The structured content will be included in the JSON-RPC response as the `structuredContent` field. - -Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array) -without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients -that only read `content` still receive the data. - -```ruby -class WeatherTool < MCP::Tool - description "Get current weather and return structured data" - - def self.call(location:, units: "celsius", server_context:) - # Call weather API and structure the response - api_response = WeatherAPI.fetch(location, units) - weather_data = { - temperature: api_response.temp, - condition: api_response.description, - humidity: api_response.humidity_percent - } - - output_schema.validate_result(weather_data) - - MCP::Tool::Response.new( - [{ - type: "text", - text: weather_data.to_json - }], - structured_content: weather_data - ) - end -end -``` - -### Tool Responses with Errors - -Tools can return error information alongside text content using the `error` parameter. - -The error will be included in the JSON-RPC response as the `isError` field. - -```ruby -class WeatherTool < MCP::Tool - description "Get current weather and return structured data" - - def self.call(server_context:) - # Do something here - content = {} - - MCP::Tool::Response.new( - [{ - type: "text", - text: content.to_json - }], - structured_content: content, - error: true - ) - end -end -``` - -### Tool Responses with Image, Audio, and Embedded Resources - -Tool responses are not limited to text. The `MCP::Content` module provides `Image`, `Audio`, and `EmbeddedResource` content types, -which serialize to the `image`, `audio`, and `resource` content blocks defined by the MCP spec. Image and audio data is passed as -a base64-encoded string together with its MIME type: - -```ruby -class ChartTool < MCP::Tool - description "Render a chart as a PNG image" - - def self.call(server_context:) - MCP::Tool::Response.new([ - MCP::Content::Text.new("Here is the rendered chart:").to_h, - MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h, - ]) - end -end - -class SpeechTool < MCP::Tool - description "Synthesize speech audio" - - def self.call(server_context:) - MCP::Tool::Response.new([ - MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h, - ]) - end -end -``` - -An embedded resource wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: - -```ruby -class ReportTool < MCP::Tool - description "Return a report as an embedded resource" - - def self.call(server_context:) - contents = MCP::Resource::TextContents.new( - uri: "report://monthly", - mime_type: "application/json", - text: { total: 42 }.to_json, - ) - - MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h]) - end -end -``` - -### Prompts - -MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. - -The `MCP::Prompt` class provides three ways to create prompts: - -1. As a class definition with metadata: - -```ruby -class MyPrompt < MCP::Prompt - prompt_name "my_prompt" # Optional - defaults to underscored class name - title "My Prompt" - description "This prompt performs specific functionality..." - arguments [ - MCP::Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ] - meta({ version: "1.0", category: "example" }) - - class << self - def template(args, server_context:) - MCP::Prompt::Result.new( - description: "Response description", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("User message") - ), - MCP::Prompt::Message.new( - role: "assistant", - content: MCP::Content::Text.new(args["message"]) - ) - ] - ) - end - end -end - -prompt = MyPrompt -``` - -2. Using the `MCP::Prompt.define` method: - -```ruby -prompt = MCP::Prompt.define( - name: "my_prompt", - title: "My Prompt", - description: "This prompt performs specific functionality...", - arguments: [ - MCP::Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ], - meta: { version: "1.0", category: "example" } -) do |args, server_context:| - MCP::Prompt::Result.new( - description: "Response description", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("User message") - ), - MCP::Prompt::Message.new( - role: "assistant", - content: MCP::Content::Text.new(args["message"]) - ) - ] - ) -end -``` - -3. Using the `MCP::Server#define_prompt` method: - -```ruby -server = MCP::Server.new -server.define_prompt( - name: "my_prompt", - description: "This prompt performs specific functionality...", - arguments: [ - Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ], - meta: { version: "1.0", category: "example" } -) do |args, server_context:| - Prompt::Result.new( - description: "Response description", - messages: [ - Prompt::Message.new( - role: "user", - content: Content::Text.new("User message") - ), - Prompt::Message.new( - role: "assistant", - content: Content::Text.new(args["message"]) - ) - ] - ) -end -``` - -The server_context parameter is the server_context passed into the server and can be used to pass per request information, -e.g. around authentication state or user preferences. - -### Key Components - -- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag -- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content -- `MCP::Prompt::Result` - The output of a prompt template containing description and messages -- `MCP::Content::Text` - Text content for messages - -### Usage - -Register prompts with the MCP server: - -```ruby -server = MCP::Server.new( - name: "my_server", - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, -) -``` - -The server will handle prompt listing and execution through the MCP protocol methods: - -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves and executes a specific prompt with arguments - -### Prompts with Image and Embedded Resource Content - -Prompt messages are not limited to text. The same `MCP::Content` types used in tool responses can be used as message content, -letting a prompt template include images or inline resource contents. Unlike tool responses, the content object is passed directly rather than as a hash; -`MCP::Prompt::Message` serializes it when the prompt result is returned: - -```ruby -class CodeReviewPrompt < MCP::Prompt - prompt_name "code_review" - description "Review a source file with an accompanying diagram" - arguments [ - MCP::Prompt::Argument.new(name: "file_uri", description: "URI of the file to review", required: true), - ] - - class << self - def template(args, server_context:) - MCP::Prompt::Result.new( - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::EmbeddedResource.new( - MCP::Resource::TextContents.new( - uri: args["file_uri"], - mime_type: "text/x-ruby", - text: read_source(args["file_uri"]), - ), - ), - ), - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Image.new(architecture_diagram_base64, "image/png"), - ), - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Please review the code above, using the diagram for context."), - ), - ], - ) - end - end -end -``` - -### Resources - -MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). - -### Reading Resources - -Like tools and prompts, resources can be defined in three ways. - -1. As a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: - -```ruby -class MyResource < MCP::Resource - uri "https://example.com/my_resource" - resource_name "my-resource" - title "My Resource" - description "Lorem ipsum dolor sit amet" - mime_type "text/html" - - class << self - def contents - [MCP::Resource::TextContents.new( - uri: uri, - mime_type: mime_type, - text: "Hello from example resource!" - )] - end - end -end - -server = MCP::Server.new( - name: "my_server", - resources: [MyResource], -) -``` - -`resources/read` requests are routed automatically: when the requested URI matches a registered -class-based resource, its `contents` method is called. `contents` may return an array of -`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. -Like tools, `contents` can opt in to a `server_context:` keyword argument to receive per-request context. - -When class-based resources or resource templates are registered and a `resources/read` request -does not match any of them, the server responds with the standard JSON-RPC Invalid Params error -(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164. - -2. With the `MCP::Resource.define` method, whose block implements `contents`: - -```ruby -resource = MCP::Resource.define( - uri: "https://example.com/my_resource", - name: "my-resource", - mime_type: "text/html", -) do - [MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")] -end -``` - -3. Using the `MCP::Server#define_resource` method: - -```ruby -server = MCP::Server.new(name: "my_server") -server.define_resource( - uri: "https://example.com/my_resource", - name: "my-resource", - mime_type: "text/html", -) do - [MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")] -end -``` - -Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, -in which case the server only lists them: - -```ruby -resource = MCP::Resource.new( - uri: "https://example.com/my_resource", - name: "my-resource", - title: "My Resource", - description: "Lorem ipsum dolor sit amet", - mime_type: "text/html", -) - -server = MCP::Server.new( - name: "my_server", - resources: [resource], -) -``` - -With plain data resources, the server must register a handler for the `resources/read` method to -retrieve a resource dynamically. - -```ruby -server.resources_read_handler do |params| - [{ - uri: params[:uri], - mimeType: "text/plain", - text: "Hello from example resource! URI: #{params[:uri]}" - }] -end -``` - -otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces -the default `resources/read` handling, including the automatic routing to class-based resources described above. - -To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, -so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array -and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. -A block that declares `server_context:` receives it: - -```ruby -server.resources_list_handler do |params, server_context:| - server_context[:authenticated] ? real_resources : demo_resources -end -``` - -The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset -into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. - -For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. -Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) -carrying the requested URI in the error `data` member: - -```ruby -server.resources_read_handler do |params| - resource = lookup(params[:uri]) - raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource - - [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }] -end -``` - -### Reading Binary Resources - -For binary resources, respond with a base64-encoded `blob` field instead of `text`. -The `MCP::Resource::TextContents` and `MCP::Resource::BlobContents` classes build the two contents shapes defined by the spec: - -```ruby -server.resources_read_handler do |params| - case params[:uri] - when "file:///logo.png" - [ - MCP::Resource::BlobContents.new( - uri: params[:uri], - mime_type: "image/png", - data: Base64.strict_encode64(File.binread("logo.png")), - ).to_h, - ] - else - [ - MCP::Resource::TextContents.new( - uri: params[:uri], - mime_type: "text/plain", - text: "Hello from example resource!", - ).to_h, - ] - end -end -``` - -### Resource Templates - -Resource templates follow the same pattern. Class-based templates declare a `uri_template` and -receive the variables extracted from the requested URI as keyword arguments to `contents`: - -```ruby -class UserProfileTemplate < MCP::ResourceTemplate - uri_template "users://{user_id}/profile" - resource_template_name "user-profile" - title "User Profile" - description "Profile data for a user" - mime_type "application/json" - - class << self - def contents(user_id:) - [MCP::Resource::TextContents.new( - uri: "users://#{user_id}/profile", - mime_type: mime_type, - text: { id: user_id }.to_json - )] - end - end -end - -server = MCP::Server.new( - name: "my_server", - resource_templates: [UserProfileTemplate], -) -``` - -A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`. -An exact match against a registered resource takes precedence over template matching. -`contents` can also opt in to a `server_context:` keyword argument. - -URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: - -- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. -- A variable matches one or more characters excluding `/`. -- Extracted values are not percent-decoded. - -The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, -mirroring the resource variants: - -```ruby -server.define_resource_template( - uri_template: "users://{user_id}/profile", - name: "user-profile", - mime_type: "application/json", -) do |user_id:| - [MCP::Resource::TextContents.new( - uri: "users://#{user_id}/profile", - mime_type: "application/json", - text: { id: user_id }.to_json - )] -end -``` - -Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`, -in which case reads must be served by a `resources_read_handler`: - -```ruby -resource_template = MCP::ResourceTemplate.new( - uri_template: "https://example.com/my_resource_template", - name: "my-resource-template", - title: "My Resource Template", - description: "Lorem ipsum dolor sit amet", - mime_type: "text/html", -) - -server = MCP::Server.new( - name: "my_server", - resource_templates: [resource_template], -) -``` - -Registered templates are listed through the `resources/templates/list` protocol method. -To serve reads for URIs that match a template, extract the variable parts of the URI in your `resources_read_handler`: - -```ruby -resource_template = MCP::ResourceTemplate.new( - uri_template: "file:///items/{item_id}", - name: "item", - mime_type: "application/json", -) - -server = MCP::Server.new(name: "my_server", resource_templates: [resource_template]) - -server.resources_read_handler do |params| - if (match = params[:uri].match(%r{\Afile:///items/(?[^/]+)\z})) - [{ - uri: params[:uri], - mimeType: "application/json", - text: { id: match[:item_id] }.to_json, - }] - else - raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) - end -end -``` - -### Roots - -The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. -Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available. - -**Key Concepts:** - -- **Server-to-Client Request**: Like sampling, roots listing is initiated by the server -- **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. - -**Timeouts:** every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely. -`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells -the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`, -which is the knob to reach for when a prompt legitimately waits on a person: - -```ruby -server_context.create_form_elicitation( - message: "Approve this deployment?", - requested_schema: { type: "object", properties: { approved: { type: "boolean" } } }, - timeout: 3600, # This one waits up to an hour. -) -``` - -`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering -surfaces as end-of-file rather than as a wait that never ends. - -**Using Roots in Tools:** - -Tools that accept a `server_context:` parameter can call `list_roots` on it. -The request is automatically routed to the correct client session: - -```ruby -class FileSearchTool < MCP::Tool - description "Search files within the client's project roots" - input_schema( - properties: { - query: { type: "string" } - }, - required: ["query"] - ) - - def self.call(query:, server_context:) - roots = server_context.list_roots - root_uris = roots[:roots].map { |root| root[:uri] } - - MCP::Tool::Response.new([{ - type: "text", - text: "Searching in roots: #{root_uris.join(", ")}" - }]) - end -end -``` - -Result contains an array of root objects: - -```ruby -{ - roots: [ - { uri: "file:///home/user/projects/myproject", name: "My Project" }, - { uri: "file:///home/user/repos/backend", name: "Backend Repository" } - ] -} -``` - -**Handling Root Changes:** - -Register a callback to be notified when the client's roots change: - -```ruby -server.roots_list_changed_handler do - puts "Client's roots have changed, tools will see updated roots on next call." -end -``` - -**Error Handling:** - -- Raises `RuntimeError` if client does not support `roots` capability -- Raises `StandardError` if client returns an error response - -### Resource Subscriptions - -Resource subscriptions allow clients to monitor specific resources for changes. -When a subscribed resource is updated, the server sends a notification to the client. - -The SDK does not track subscription state internally. -Server developers register handlers and manage their own subscription state. -Three methods are provided: - -- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests -- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests -- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client - -```ruby -subscribed_uris = Set.new - -server = MCP::Server.new( - name: "my_server", - resources: [my_resource], - capabilities: { resources: { subscribe: true } }, -) - -server.resources_subscribe_handler do |params| - subscribed_uris.add(params[:uri].to_s) -end - -server.resources_unsubscribe_handler do |params| - subscribed_uris.delete(params[:uri].to_s) -end - -server.define_tool(name: "update_resource") do |server_context:, **args| - if subscribed_uris.include?("test://my-resource") - server_context.notify_resources_updated(uri: "test://my-resource") - end - MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h]) -end -``` - -The `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows -alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it -returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` -rather than returning it at the top level, which interoperating clients reject: - -```ruby -server.resources_subscribe_handler do |params| - id = subscriptions.create(params[:uri].to_s) - { _meta: { "myapp.example/subscriptionId" => id } } -end -``` - -### Sampling - -The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. -This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. - -**Key Concepts:** - -- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server -- **Client Capability**: Clients must declare `sampling` capability during initialization -- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability -- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs - -**Using Sampling in Tools:** - -Tools that accept a `server_context:` parameter can call `create_sampling_message` on it. -The request is automatically routed to the correct client session: - -```ruby -class SummarizeTool < MCP::Tool - description "Summarize text using LLM" - input_schema( - properties: { - text: { type: "string" } - }, - required: ["text"] - ) - - def self.call(text:, server_context:) - result = server_context.create_sampling_message( - messages: [ - { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } - ], - max_tokens: 500 - ) - - MCP::Tool::Response.new([{ - type: "text", - text: result[:content][:text] - }]) - end -end - -server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) -``` - -**Parameters:** - -Required: - -- `messages:` (Array) - Array of message objects with `role` and `content` -- `max_tokens:` (Integer) - Maximum tokens in the response - -Optional: - -- `system_prompt:` (String) - System prompt for the LLM -- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) -- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) -- `temperature:` (Float) - Sampling temperature -- `stop_sequences:` (Array) - Sequences that stop generation -- `metadata:` (Hash) - Additional metadata -- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) -- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) - -**Error Handling:** - -- Raises `RuntimeError` if client does not support `sampling` capability -- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability -- Raises `StandardError` if client returns an error response - -### Notifications - -The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. - -#### Notification Methods - -The server provides the following notification methods: - -- `notify_tools_list_changed` - Send a notification when the tools list changes -- `notify_prompts_list_changed` - Send a notification when the prompts list changes -- `notify_resources_list_changed` - Send a notification when the resources list changes -- `notify_log_message` - Send a structured logging notification message - -#### Session Scoping - -When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: - -- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client. -No extra configuration is needed. -- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, -as they represent server-wide state changes. These should be called on the `server` instance directly. - -#### Notification Format - -Notifications follow the JSON-RPC 2.0 specification and use these method names: - -- `notifications/tools/list_changed` -- `notifications/prompts/list_changed` -- `notifications/resources/list_changed` -- `notifications/cancelled` -- `notifications/progress` -- `notifications/message` - -### Cancellation - -The MCP Ruby SDK supports server-side handling of the -[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation). -When a client sends `notifications/cancelled` for an in-flight request, the server stops -processing cooperatively and suppresses the JSON-RPC response for that request. - -Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead, -a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools -poll it to exit early. When a tool returns after cancellation has been observed, -the server suppresses the JSON-RPC response, matching the spec. The `initialize` request -is never cancellable per the spec. - -Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below. - -#### Server-Side: Handlers that Check for Cancellation - -Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, -`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`, -`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives -an `MCP::ServerContext` wired to the in-flight request's cancellation token. -Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise -`MCP::CancelledError` at a safe point: - -```ruby -class LongRunningTool < MCP::Tool - description "A tool that supports cancellation" - input_schema(properties: { count: { type: "integer" } }, required: ["count"]) - - def self.call(count:, server_context:) - count.times do |i| - # Exit early if the client has sent `notifications/cancelled`. - break if server_context.cancelled? - - do_work(i) - end - - MCP::Tool::Response.new([{ type: "text", text: "Done" }]) - end -end -``` - -Alternatively, raise at the next safe point with `raise_if_cancelled!`: - -```ruby -def self.call(count:, server_context:) - count.times do |i| - server_context.raise_if_cancelled! - - do_work(i) - end - - MCP::Tool::Response.new([{ type: "text", text: "Done" }]) -end -``` - -When a handler observes cancellation (either by returning early with `cancelled?` or -by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and -no JSON-RPC result is sent to the client. - -The same pattern works for other handler types: - -```ruby -# resources/read -server.resources_read_handler do |params, server_context:| - server_context.raise_if_cancelled! - # read the resource -end - -# completion/complete -server.completion_handler do |params, server_context:| - server_context.raise_if_cancelled! - # compute completions -end - -# custom method -server.define_custom_method(method_name: "custom/slow") do |params, server_context:| - server_context.raise_if_cancelled! - # do work -end - -# prompts (via Prompt subclass) -class SlowPrompt < MCP::Prompt - prompt_name "slow_prompt" - - def self.template(args, server_context:) - server_context.raise_if_cancelled! - MCP::Prompt::Result.new(messages: []) - end -end -``` - -Handlers that do not declare a `server_context:` keyword continue to work unchanged - -the opt-in detection only wraps the context when the block signature asks for it. - -#### Nested Server-to-Client Requests Are Cancelled Automatically - -When a tool handler is waiting on a nested server-to-client request -(`server_context.create_sampling_message`, `create_form_elicitation`, or -`create_url_elicitation`), cancelling the parent tool call automatically raises -`MCP::CancelledError` from the nested call, so the tool does not need to wrap it -in its own `cancelled?` checks: - -```ruby -def self.call(server_context:) - result = server_context.create_sampling_message(messages: messages, max_tokens: 100) - # If the parent tools/call is cancelled while waiting above, MCP::CancelledError - # is raised here and the tool can let it propagate or clean up as needed. - MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) -rescue MCP::CancelledError - # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server - # will still suppress the JSON-RPC response per the MCP spec. - raise -end -``` - -Nested cancellation propagation is supported on `StreamableHTTPTransport` only. -`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested -`server_context.create_sampling_message` inside a tool runs to completion even if -the parent `tools/call` is cancelled. The parent tool itself still observes cancellation -via `server_context.cancelled?` between nested calls. - -#### Client-Side: Cancelling an In-Flight Request - -`MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass -an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call -`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to -the server, and the calling thread is woken up with `MCP::CancelledError`: - -```ruby -client = MCP::Client.new(transport: transport) -cancellation = MCP::Cancellation.new - -Thread.new do - client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) -rescue MCP::CancelledError - # cleanup -end - -# Later, from another thread: -cancellation.cancel(reason: "user pressed cancel") -``` - -All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, -`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword. -Request ids are managed internally, so the token is the only thing a caller needs to cancel a request. - -> [!NOTE] -> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed; -> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side -> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` -> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` -> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal -> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at -> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. - -##### Wire-order guarantees - -`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, -so the server is guaranteed to read the request line before the cancel line. - -`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, -so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST -on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and -still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)), -and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST -happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. - -##### Custom transports - -Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. -They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire -(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). -The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for -the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. - -### Ping - -The MCP Ruby SDK supports the -[MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), -which allows either side of the connection to verify that the peer is still responsive. -A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. - -#### Server-Side - -Servers respond to incoming `ping` requests automatically - no setup is required. -Any `MCP::Server` instance replies with an empty result. - -Servers can also send `ping` requests to the client via `ServerSession#ping`. -Inside a tool handler that receives `server_context:`, call `ping` on it: - -```ruby -class HealthCheckTool < MCP::Tool - description "Verifies the client is still responsive" - - def self.call(server_context:) - server_context.ping # => {} on success - - MCP::Tool::Response.new([{ type: "text", text: "client is alive" }]) - end -end -``` - -`#ping` raises `MCP::Server::ValidationError` when the client returns a `result` -that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error) -propagate as exceptions raised by the transport layer. - -#### Client-Side - -`MCP::Client` exposes `ping` to send a ping to the server: - -```ruby -client = MCP::Client.new(transport: transport) -client.ping # => {} on success -``` - -`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. -It raises `MCP::Client::ValidationError` when the response `result` is missing or -is not a Hash (matching the spec requirement that `result` be an object). -Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) -propagate as exceptions raised by the transport layer. - -### Progress - -The MCP Ruby SDK supports progress tracking for long-running tool operations, -following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). - -#### How Progress Works - -1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool -2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution -3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress - -#### Server-Side: Tool with Progress - -Tools that accept a `server_context:` parameter can call `report_progress` on it. -The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: - -```ruby -class LongRunningTool < MCP::Tool - description "A tool that reports progress during execution" - input_schema( - properties: { - count: { type: "integer" }, - }, - required: ["count"] - ) - - def self.call(count:, server_context:) - count.times do |i| - # Do work here. - server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}") - end - - MCP::Tool::Response.new([{ type: "text", text: "Done" }]) - end -end -``` - -The `server_context.report_progress` method accepts: - -- `progress` (required) — current progress value (numeric) -- `total:` (optional) — total expected value, so clients can display a percentage -- `message:` (optional) — human-readable status message - -**Key Features:** - -- Tools report progress via `server_context.report_progress` -- `report_progress` is a no-op when no `progressToken` was provided by the client -- Supports both numeric and string progress tokens - -### Completions - -MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), -which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. - -To enable completions, declare the `completions` capability and register a handler: - -```ruby -server = MCP::Server.new( - name: "my_server", - prompts: [CodeReviewPrompt], - resource_templates: [FileTemplate], - capabilities: { completions: {} }, -) - -server.completion_handler do |params| - ref = params[:ref] - argument = params[:argument] - value = argument[:value] - - case ref[:type] - when "ref/prompt" - values = case argument[:name] - when "language" - ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } - else - [] - end - { completion: { values: values, hasMore: false } } - when "ref/resource" - { completion: { values: [], hasMore: false } } - end -end -``` - -The handler receives a `params` hash with: - -- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) -- `argument` - The argument being completed (`{ name: "...", value: "..." }`) -- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) - -The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. -The SDK automatically enforces the 100-item limit per the MCP specification. - -The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. -Requests for unknown references return an error. - -### Elicitation - -The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation), -which allows servers to request additional information from users through the client during tool execution. - -Elicitation is a **server-to-client request**. The server sends a request and blocks until the user responds via the client. - -#### Capabilities - -Clients must declare the `elicitation` capability during initialization. The server checks this before sending any elicitation request -and raises a `RuntimeError` if the client does not support it. - -For URL mode support, the client must also declare `elicitation.url` capability. - -#### Using Elicitation in Tools - -Tools that accept a `server_context:` parameter can call `create_form_elicitation` on it: - -```ruby -server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:| - result = server_context.create_form_elicitation( - message: "Please provide your name", - requested_schema: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }, - ) - - MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }]) -end -``` - -#### Form Mode - -Form mode collects structured data from the user directly through the MCP client: - -```ruby -server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:| - result = server_context.create_form_elicitation( - message: "Please provide your contact information", - requested_schema: { - type: "object", - properties: { - name: { type: "string", description: "Your full name" }, - email: { type: "string", format: "email", description: "Your email address" }, - }, - required: ["name", "email"], - }, - ) - - text = case result[:action] - when "accept" - "Hello, #{result[:content][:name]} (#{result[:content][:email]})" - when "decline" - "User declined" - when "cancel" - "User cancelled" - end - - MCP::Tool::Response.new([{ type: "text", text: text }]) -end -``` - -The `requested_schema` must be a flat object schema: a top-level `type: "object"` whose `properties` are limited to -primitive types (`string`, `number`, `integer`, `boolean`). Nested objects and arrays are not allowed, which keeps -the schema simple enough for clients to render as a form. Per the MCP specification, the client validates -the user's input against this schema before returning it, so the `content` of an `accept` response matches the requested shape. - -#### Default Values and Enums - -Properties may declare a `default` value (SEP-1034), which clients use to pre-fill the form. -String properties may declare `enum` values, optionally with human-readable `enumNames` (SEP-1330), which clients render as a choice list: - -```ruby -server.define_tool(name: "configure_deploy", description: "Configure a deployment") do |server_context:| - result = server_context.create_form_elicitation( - message: "Configure the deployment", - requested_schema: { - type: "object", - properties: { - replicas: { type: "integer", default: 3 }, - verbose: { type: "boolean", default: false }, - environment: { - type: "string", - enum: ["dev", "staging", "prod"], - enumNames: ["Development", "Staging", "Production"], - default: "dev", - }, - }, - required: ["environment"], - }, - ) - - MCP::Tool::Response.new([{ type: "text", text: "Deploying to #{result[:content][:environment]}" }]) -end -``` - -#### Enum Schemas - -For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per -[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) instead of building -the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select, -plus the legacy `enumNames` form retained for backward compatibility: - -```ruby -size_schema = MCP::Elicitation::EnumSchema.titled_single_select( - options: [ - { value: "s", title: "Small" }, - { value: "m", title: "Medium" }, - { value: "l", title: "Large" }, - ], - default: "m", -) - -tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select( - values: ["urgent", "billing", "feedback"], -) - -result = server_context.create_form_elicitation( - message: "Tell us about your order", - requested_schema: { - type: "object", - properties: { - size: size_schema.to_h, - tags: tags_schema.to_h, - }, - required: ["size"], - }, -) -``` - -The available builders are `untitled_single_select`, `titled_single_select`, `untitled_multi_select`, `titled_multi_select`, -and `legacy_titled`. Each accepts optional `default:`, `title:`, and `description:`. - -The same builders produce the `requestedSchema` of an `elicitation/create` request embedded in a SEP-2322 `input_required` result, -which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle: - -```ruby -MCP::Server::InputRequiredResult.new( - input_requests: { - "size" => { - method: "elicitation/create", - params: { - message: "Pick a size", - requestedSchema: { - type: "object", - properties: { size: size_schema.to_h }, - required: ["size"], - }, - }, - }, - }, -) -``` - -#### URL Mode - -URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: - -```ruby -server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:| - elicitation_id = SecureRandom.uuid - - result = server_context.create_url_elicitation( - message: "Please authorize access to your GitHub account", - url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}", - elicitation_id: elicitation_id, - ) - - server_context.notify_elicitation_complete(elicitation_id: elicitation_id) - - MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }]) -end -``` - -#### URLElicitationRequiredError - -When a tool cannot proceed until an out-of-band elicitation is completed, raise `MCP::Server::URLElicitationRequiredError`. -This returns a JSON-RPC error with code `-32042` to the client: - -```ruby -server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:| - raise MCP::Server::URLElicitationRequiredError.new([ - { - mode: "url", - elicitationId: SecureRandom.uuid, - url: "https://example.com/oauth/authorize", - message: "GitHub authorization is required.", - }, - ]) -end -``` - -### Logging - -The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -The `notifications/message` notification is used for structured logging between client and server. - -#### Log Levels - -The SDK supports 8 log levels with increasing severity: - -- `debug` - Detailed debugging information -- `info` - General informational messages -- `notice` - Normal but significant events -- `warning` - Warning conditions -- `error` - Error conditions -- `critical` - Critical conditions -- `alert` - Action must be taken immediately -- `emergency` - System is unusable - -#### How Logging Works - -1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level -2. **Server Filtering**: The server only sends log messages at the configured level or higher severity -3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client - -For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. - -For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -**Usage Example:** - -```ruby -server = MCP::Server.new(name: "my_server") -transport = MCP::Server::Transports::StdioTransport.new(server) - -# The client first configures the logging level (on the client side): -transport.send_request( - request: { - jsonrpc: "2.0", - method: "logging/setLevel", - params: { level: "info" }, - id: session_id # Unique request ID within the session - } -) - -# Send log messages at different severity levels -server.notify_log_message( - data: { message: "Application started successfully" }, - level: "info" -) - -server.notify_log_message( - data: { message: "Configuration file not found, using defaults" }, - level: "warning" -) - -server.notify_log_message( - data: { - error: "Database connection failed", - details: { host: "localhost", port: 5432 } - }, - level: "error", - logger: "DatabaseLogger" # Optional logger name -) -``` - -**Key Features:** - -- Supports 8 log levels (debug, info, notice, warning, error, critical, alert, emergency) based on https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#log-levels -- Server has capability `logging` to send log messages -- Messages are only sent if a transport is configured -- Messages are filtered based on the client's configured log level -- If the log level hasn't been set by the client, no messages will be sent - -#### Transport Support - -- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout -- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) - -#### Usage Example - -```ruby -server = MCP::Server.new(name: "my_server") - -# Default Streamable HTTP - session oriented -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -# When tools change, notify clients -server.define_tool(name: "new_tool") { |**args| { result: "ok" } } -server.notify_tools_list_changed - -# When prompts change, notify clients -server.define_prompt(name: "new_prompt") do |args, server_context:| - MCP::Prompt::Result.new(messages: []) -end -server.notify_prompts_list_changed - -# When resources change, notify clients -server.define_resource(uri: "resource://new", name: "new_resource", mime_type: "text/plain") do - [MCP::Resource::TextContents.new(uri: "resource://new", mime_type: "text/plain", text: "contents")] -end -server.notify_resources_list_changed -``` - -You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. -This mode allows for easy multi-node deployment. -Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`): - -```ruby -# Stateless Streamable HTTP - session-less -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) -``` - -In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required, -handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server), -and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped -(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. - -You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. -Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: - -```ruby -# JSON response mode -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true) -``` - -In JSON response mode, the POST response is a single JSON object, so server-to-client messages -that need to arrive during request processing are not supported: -request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests -(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. -Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and -broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream. -This mode is suitable for simple tool servers that do not need server-initiated requests. - -By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted: -they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent -session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it -(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute, -so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots -and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session). - -```ruby -# Tune the limits -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000) - -# Opt out of expiry and/or the cap (not recommended on internet-facing deployments) -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil) -``` - -Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. - -#### Session Ownership - -`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session -existence and idle timeout only. It does not bind a session to a user, because the transport never receives -an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, -so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). - -The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` -on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, -so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return -rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded -when the session was created: - -```ruby -transport = MCP::Server::Transports::StreamableHTTPTransport.new( - server, - session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, -) -``` - -Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), -it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only -when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. -Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. - -#### Request Size Limits - -`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory -with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, -and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON -string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only -if you exchange unusually large payloads: - -```ruby -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) -``` - -### Pagination - -The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) -for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, -treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. - -Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`. - -#### Server-Side: Enabling Pagination - -Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), -list responses contain all items in a single response, preserving the pre-pagination behavior. - -```ruby -server = MCP::Server.new( - name: "my_server", - tools: tools, - page_size: 50, -) -``` - -When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "tools": [ - { "name": "example_tool" } - ], - "nextCursor": "50" - } -} -``` - -Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. - -#### Client-Side: Iterating Pages - -`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. -**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection. -The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors: - -```ruby -client = MCP::Client.new(transport: transport) - -cursor = nil -loop do - page = client.list_tools(cursor: cursor) - page.tools.each { |tool| process(tool) } - cursor = page.next_cursor - break unless cursor -end -``` - -The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and -`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page. - -Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration: - -| Server `page_size` | `client.list_tools(cursor: nil)` | -|--------------------|---------------------------------------------------------------------| -| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. | -| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. | - -If your application needs the complete collection regardless of how the server is configured, either loop on -`next_cursor` as shown above, or use the whole-collection methods described below. - -#### Fetching the Complete Collection - -`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate -through all pages and return a plain array of items, guaranteeing the full collection regardless -of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round -trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor` -it has already sent, and it stops after `max_pages` pages. - -```ruby -tools = client.tools # => Array of every tool on the server. -``` - -`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods -will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that -point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it -if you legitimately expect more pages than that. - -Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need -fine-grained iteration (e.g. to stream-process pages without loading everything into memory). - -#### List Result Caching (`ttlMs` / `cacheScope`) - -Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; -`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). - -Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, -`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "private"`, -the scope that keeps a potentially user-dependent result out of shared caches). -When neither is set, responses are serialized exactly as before. -The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern `_meta` envelope -the server always emits them, filling unset values with the same defaults; stable protocol versions keep the opt-in behavior. - -```ruby -server = MCP::Server.new( - name: "my_server", - tools: tools, - ttl_ms: 60_000, # results stay fresh for one minute - cache_scope: "private", # only the requesting client may cache them -) -``` - -A `resources_read_handler` can override the hints per result by returning a full result hash instead of bare contents: - -```ruby -server.resources_read_handler do |params| - { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } -end -``` - -On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: - -```ruby -page = client.list_tools -page.ttl_ms # => 60000 (nil when the server sent no hint) -page.cache_scope # => "private" -``` - -### Advanced - -#### Custom Methods - -The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: - -```ruby -server = MCP::Server.new(name: "my_server") - -# Define a custom method that returns a result -server.define_custom_method(method_name: "add") do |params| - params[:a] + params[:b] -end - -# Define a custom notification method (returns nil) -server.define_custom_method(method_name: "notify") do |params| - # Process notification - nil -end -``` - -**Key Features:** - -- Accepts any method name as a string -- Block receives the request parameters as a hash -- Can handle both regular methods (with responses) and notifications -- Prevents overriding existing MCP protocol methods -- Supports instrumentation callbacks for monitoring - -**Usage Example:** - -```ruby -# Client request -{ - "jsonrpc": "2.0", - "id": 1, - "method": "add", - "params": { "a": 5, "b": 3 } -} - -# Server response -{ - "jsonrpc": "2.0", - "id": 1, - "result": 8 -} -``` - -**Error Handling:** - -- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method -- Supports the same exception reporting and instrumentation as standard methods - -## Building an MCP Client - -The `MCP::Client` class provides an interface for interacting with MCP servers. - -This class supports: - -- Liveness check via the `ping` method (`MCP::Client#ping`) -- Tool listing via the `tools/list` method (`MCP::Client#tools`) -- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) -- Resource listing via the `resources/list` method (`MCP::Client#resources`) -- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) -- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) -- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) -- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) -- Completion requests via the `completion/complete` method (`MCP::Client#complete`) -- Automatic JSON-RPC 2.0 message formatting -- UUID request ID generation - -Clients are initialized with a transport layer instance that handles the low-level communication mechanics. -Authorization is handled by the transport layer. - -### Lifecycle Negotiation (SEP-2575) - -`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled -`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes `server/discover` first and adopts -the stateless modern lifecycle (MCP 2026-07-28) when the server serves it, falling back to -the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare -a `mode:` keyword always receive the classic call shape, unchanged. - -```ruby -client.connect # negotiate automatically (default) -client.connect(mode: :legacy) # force the classic initialize handshake -client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers -client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe -``` - -Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process) -and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on -the legacy lifecycle. - -Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result, -their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`, -top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`) -on modern. Code that should work against both lifecycles can use the era-independent readers instead: - -```ruby -client.protocol_version # negotiated or adopted version, either lifecycle -client.server_capabilities # capabilities Hash, either lifecycle -client.instructions # instructions text, either lifecycle -client.server_implementation # server name/version; nil when a modern server does not identify itself -``` - -Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded, -the server now serves the modern lifecycle and the automatic negotiation adopted it. -Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. - -### Custom Headers from Tool Parameters (SEP-2243) - -On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries -an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers, so intermediaries can route -on the values without parsing bodies. The declarations are learned from `tools/list` responses: -list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values -(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`, -and a `null` or absent argument omits its header. - -Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names, -duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from -`tools/list` results on modern connections, with a warning naming the tool. -Legacy connections are unaffected: nothing is learned, mirrored, or excluded. - -## Transport Layer Interface - -If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: - -```ruby -class CustomTransport - # Sends a JSON-RPC request to the server and returns the raw response. - # - # @param request [Hash] A complete JSON-RPC request object. - # https://www.jsonrpc.org/specification#request_object - # @return [Hash] A hash modeling a JSON-RPC response object. - # https://www.jsonrpc.org/specification#response_object - def send_request(request:) - # Your transport-specific logic here - # - HTTP: POST to endpoint with JSON body - # - WebSocket: Send message over WebSocket - # - stdio: Write to stdout, read from stdin - # - etc. - end -end -``` - -### Stdio Transport Layer - -Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. - -`MCP::Client::Stdio.new` accepts the following keyword arguments: - -| Parameter | Required | Description | -|---|---|---| -| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). | -| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | -| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | -| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | -| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | - -Example usage: +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: ```ruby stdio_transport = MCP::Client::Stdio.new( @@ -2669,421 +120,20 @@ response = client.call_tool( stdio_transport.close ``` -The stdio transport automatically handles: +The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transports/). -- Spawning the server process with `Open3.popen3` -- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) -- JSON-RPC 2.0 message framing over newline-delimited JSON +## Examples -### HTTP Transport Layer - -Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. - -You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: - -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses -``` - -Example usage: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") -client = MCP::Client.new(transport: http_transport) - -# Perform the MCP initialization handshake before sending any requests. -client.connect - -# List available tools -tools = client.tools -tools.each do |tool| - puts <<~TOOL_INFORMATION - Tool: #{tool.name} - Description: #{tool.description} - Input Schema: #{tool.input_schema} - TOOL_INFORMATION -end - -# Call a specific tool -response = client.call_tool( - tool: tools.first, - arguments: { message: "Hello, world!" } -) - -# Call a tool with progress tracking. -response = client.call_tool( - tool: tools.first, - arguments: { count: 10 }, - progress_token: "my-progress-token" -) -``` - -The server will send `notifications/progress` back to the client during execution. - -`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server - -an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from -a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses. - -`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts, -and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread, -so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes). -The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead, -the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin -the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at. - -#### Server-to-Client Requests (Elicitation) - -Servers can send requests back to the client while one of the client's own requests is in flight - for example, -[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. -Register a handler and advertise the capability on `connect` to respond to them: - -```ruby -client.connect(capabilities: { elicitation: {} }) - -client.on_elicitation do |params| - { - action: "accept", - # Fill fields omitted by the user with the schema's `default` values (SEP-1034) - content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), - } -end -``` - -Registering a handler opens a standalone HTTP GET SSE stream on a background thread -([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)), -since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with -a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with -`http_transport.on_server_request("method/name") { |params| ... }`. - -#### Server-to-Client Requests (Sampling) - -Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), -letting a server leverage the client's model access without its own API keys. - -> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`. -> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; -> new servers should call LLM provider APIs directly. - -Register a handler and advertise the capability on `connect`: - -```ruby -client.connect(capabilities: { sampling: {} }) - -client.on_sampling do |params| - completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) - { - role: "assistant", - content: { type: "text", text: completion.text }, - model: completion.model, - stopReason: "endTurn", - } -end -``` - -For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. -To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: - -```ruby -client.on_sampling do |params| - raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) - - generate_completion(params) -end -``` - -Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. - -#### HTTP Authorization - -By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: - -```ruby -http_transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) - -client = MCP::Client.new(transport: http_transport) -client.tools # will make the call using Bearer auth -``` - -You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. - -#### OAuth 2.1 Authorization - -When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), -pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: - -- Send `Authorization: Bearer ` on every request when a token is available. -- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), - perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. -- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts - as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), - and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. -- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). -- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), - run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. - The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. -- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata - `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise - `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. - -```ruby -require "mcp" - -provider = MCP::Client::OAuth::Provider.new( - client_metadata: { - client_name: "My MCP App", - redirect_uris: ["http://localhost:3030/callback"], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:3030/callback", - redirect_handler: ->(authorization_url) { - # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` - # or a manual `puts authorization_url` in CLI tools. - }, - callback_handler: -> { - # Capture the redirect (for example, by running a small HTTP listener on - # `redirect_uri`) and return [code, state] from the query string. - }, -) - -transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - oauth: provider, -) -client = MCP::Client.new(transport: transport) -client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token -client.tools -``` - -Required keyword arguments to `Provider.new`: - -- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, - `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. - When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); - an explicit value always wins. -- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. -- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. -- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form - (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match - the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. - -Optional keyword arguments: - -- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. -- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, - which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that - issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically - (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. -- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document - (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). - When the authorization server advertises `client_id_metadata_document_supported: true`, - the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. - Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, - userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks - them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. - Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document - served at the URL is a separate JSON artifact from the `client_metadata` keyword above: - the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include - `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. - -To persist credentials across restarts, supply your own storage: - -```ruby -class FileTokenStorage - def initialize(path) - @path = path - end - - def tokens - read["tokens"] - end - - def save_tokens(value) - write("tokens" => value) - end - - def client_information - read["client"] - end - - def save_client_information(value) - write("client" => value) - end - - private - - def read - File.exist?(@path) ? JSON.parse(File.read(@path)) : {} - end - - def write(updates) - File.write(@path, JSON.dump(read.merge(updates))) - end -end - -provider = MCP::Client::OAuth::Provider.new( - # ... required keywords ... - storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), -) -``` - -##### Client Credentials Grant - -For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. -The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at -the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. - -```ruby -provider = MCP::Client::OAuth::ClientCredentialsProvider.new( - client_id: "my-service", - client_secret: ENV.fetch("MCP_CLIENT_SECRET"), - # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post" - # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) -) - -transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) -``` - -Keyword arguments: - -- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory. -- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`. -- `scope`, `storage`: Optional, same meaning as on `Provider`. - -##### Cross-App Access (JWT Bearer) Grant - -For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. -The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG -to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. -Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. - -`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into -an enterprise secret store or a test double without changing the transport wiring. - -```ruby -provider = MCP::Client::OAuth::CrossAppAccessProvider.new( - client_id: "my-mcp-client", - client_secret: ENV.fetch("MCP_CLIENT_SECRET"), - assertion_provider: ->(audience:, resource:) { - MCP::Client::OAuth::IDJAGTokenExchange.request( - token_endpoint: "https://idp.example.com/token", - id_token: ENV.fetch("IDP_ID_TOKEN"), - client_id: "my-idp-client", - audience: audience, - resource: resource, - ) - }, - # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) -) - -transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) -``` - -Keyword arguments: - -- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. -- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. - `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). - Passing both through to `IDJAGTokenExchange.request` covers the common case. -- `scope`, `storage`: Optional, same meaning as on `Provider`. - -##### Communication Security - -When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, -`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. - -The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through -a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, -whether the swap was triggered by -`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, -and whether the new URL is `http://` *or* `https://` to a different host or tenant. - -##### Discovery URL Destinations - -The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations. -Both checks run before the request is sent, and neither is configurable. - -- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, - so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, - which does not require it. -- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be - IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). - The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`, - and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. -- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, - and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. - -The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed, -such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one -the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly. - -If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against -the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. - -The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, -measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. -Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through -`http_client_factory:` is bounded as well, so there is no way to opt out of it. - -#### Customizing the Faraday Connection - -You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. -The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| - faraday.use MyApp::Middleware::HttpRecorder - faraday.adapter :typhoeus -end -``` - -### Tool Objects - -The client provides a wrapper class for tools returned by the server: - -- `MCP::Client::Tool` - Represents a single tool with its metadata - -This class provides easy access to tool properties like name, description, input schema, and output schema. - -### Multi-Round-Trip Results (Experimental, SEP-2322) - -The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi Round-Trip Requests: instead of issuing `sampling/createMessage`, `roots/list`, -or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map -and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. - -The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, -and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` -(10 by default, matching the TypeScript and Python SDKs). Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; -the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords. -`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. - -SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 server returns. The server stamps `resultType: "complete"` on all results of requests carrying -the modern `_meta` envelope (and on `server/discover` results), while results that already carry a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. -Legacy results stay unstamped, and clients treat an absent `resultType` as `"complete"` per the spec. - -#### Dual-era authoring (legacy fulfilment shim) - -Handlers written in the 2026 style serve pre-2026 clients too: when a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on the legacy wire, -the server fulfills it in place of the client's driver. Each `inputRequests` entry is sent as the equivalent real server-to-client request -(`elicitation/create`, `sampling/createMessage`, `roots/list`), associated with the originating request per SEP-2260; the answers are collected under the same keys, -and the handler re-runs with `server_context.input_responses` populated and the raw `requestState` echoed, the same deterministic replay contract the modern client driver follows. -The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; `MCP::Server.new(input_required_legacy_shim: false)` restores the strict rejection of `input_required` results on legacy requests. - -## Conformance Testing - -The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). - -See [conformance/README.md](conformance/README.md) for usage instructions. +Runnable examples are available in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples), +including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). ## Documentation +- [SDK guides](https://ruby.sdk.modelcontextprotocol.io) - [SDK API documentation](https://rubydoc.info/gems/mcp) - [Model Context Protocol documentation](https://modelcontextprotocol.io) + +## License + +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md new file mode 100644 index 0000000..1c73eeb --- /dev/null +++ b/docs/_client/authorization.md @@ -0,0 +1,260 @@ +--- +layout: default +title: Authorization +nav_order: 8 +--- + +# Authorization + +Authorization is handled by the transport layer. This page covers authenticating the HTTP transport, +from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements +(PKCE with dynamic client registration, the client credentials grant, and cross-app access). + +## HTTP Authorization + +By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: + +```ruby +http_transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) + +client = MCP::Client.new(transport: http_transport) +client.tools # will make the call using Bearer auth +``` + +You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. + +## OAuth 2.1 Authorization + +When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization), +pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: + +- Send `Authorization: Bearer ` on every request when a token is available. +- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), + perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. +- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts + as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), + and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. +- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). +- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), + run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. + The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. +- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata + `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise + `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. + +```ruby +require "mcp" + +provider = MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "My MCP App", + redirect_uris: ["http://localhost:3030/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:3030/callback", + redirect_handler: ->(authorization_url) { + # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` + # or a manual `puts authorization_url` in CLI tools. + }, + callback_handler: -> { + # Capture the redirect (for example, by running a small HTTP listener on + # `redirect_uri`) and return [code, state] from the query string. + }, +) + +transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + oauth: provider, +) +client = MCP::Client.new(transport: transport) +client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token +client.tools +``` + +Required keyword arguments to `Provider.new`: + +- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, + `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. + When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); + an explicit value always wins. +- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. +- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. +- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form + (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match + the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. + +Optional keyword arguments: + +- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, + which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that + issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically + (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. +- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document + (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). + When the authorization server advertises `client_id_metadata_document_supported: true`, + the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, + userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks + them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. + Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document + served at the URL is a separate JSON artifact from the `client_metadata` keyword above: + the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include + `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. + +{: .warning } +> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents, +> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it +> automatically wherever the authorization server advertises support. + +To persist credentials across restarts, supply your own storage: + +```ruby +class FileTokenStorage + def initialize(path) + @path = path + end + + def tokens + read["tokens"] + end + + def save_tokens(value) + write("tokens" => value) + end + + def client_information + read["client"] + end + + def save_client_information(value) + write("client" => value) + end + + private + + def read + File.exist?(@path) ? JSON.parse(File.read(@path)) : {} + end + + def write(updates) + File.write(@path, JSON.dump(read.merge(updates))) + end +end + +provider = MCP::Client::OAuth::Provider.new( + # ... required keywords ... + storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), +) +``` + +### Client Credentials Grant + +For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. +The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + # token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt" + # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`: Required. `client_secret`: Required with the secret-based methods; the grant is + for confidential clients, so a credential is mandatory. +- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`, + or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected + with `ClientCredentialsProvider::InvalidCredentialsError`. +- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string + or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` + or `"RS256"`; `client_secret` must not be set, because the private key is the credential. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + +### Cross-App Access (JWT Bearer) Grant + +For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. +The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG +to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. +Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. + +`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into +an enterprise secret store or a test double without changing the transport wiring. + +```ruby +provider = MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: "my-mcp-client", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + assertion_provider: ->(audience:, resource:) { + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: "https://idp.example.com/token", + id_token: ENV.fetch("IDP_ID_TOKEN"), + client_id: "my-idp-client", + audience: audience, + resource: resource, + ) + }, + # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. +- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. + `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). + Passing both through to `IDJAGTokenExchange.request` covers the common case. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + +### Communication Security + +When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, +`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. + +The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through +a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, +whether the swap was triggered by +`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, +and whether the new URL is `http://` *or* `https://` to a different host or tenant. + +### Discovery URL Destinations + +The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations. +Both checks run before the request is sent, and neither is configurable. + +- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, + so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, + which does not require it. +- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be + IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). + The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`, + and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. +- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, + and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. + +The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed, +such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one +the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly. + +If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against +the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. + +The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, +measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. +Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through +`http_client_factory:` is bounded as well, so there is no way to opt out of it. diff --git a/docs/_client/cancellation.md b/docs/_client/cancellation.md new file mode 100644 index 0000000..2a9409c --- /dev/null +++ b/docs/_client/cancellation.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Cancellation +nav_order: 5 +--- + +# Cancellation + +`MCP::Client` lets the caller cancel a request it has already issued, +per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +The recommended pattern is to pass +an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call +`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to +the server, and the calling thread is woken up with `MCP::CancelledError`: + +```ruby +client = MCP::Client.new(transport: transport) +cancellation = MCP::Cancellation.new + +Thread.new do + client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) +rescue MCP::CancelledError + # cleanup +end + +# Later, from another thread: +cancellation.cancel(reason: "user pressed cancel") +``` + +All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, +`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword. +Request ids are managed internally, so the token is the only thing a caller needs to cancel a request. + +{: .note } +> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed; +> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side +> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` +> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` +> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal +> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at +> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. + +{: .note } +> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight +> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests +> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still +> raises `MCP::CancelledError` - but the server runs the request to completion. + +## Wire-order guarantees + +`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, +so the server is guaranteed to read the request line before the cancel line. + +`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, +so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST +on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and +still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)), +and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST +happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. + +## Custom transports + +Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. +They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire +(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). +The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for +the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. + +## Server Side + +How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page. diff --git a/docs/_client/index.md b/docs/_client/index.md new file mode 100644 index 0000000..1f8e541 --- /dev/null +++ b/docs/_client/index.md @@ -0,0 +1,46 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /client/ +redirect_from: + - /building-clients.html + - /building-clients/ +--- + +# Building an MCP Client + +The `MCP::Client` class provides an interface for interacting with MCP servers. + +This class supports: + +- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle + when the server serves it; see [Lifecycle](/client/lifecycle/) +- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery) +- Liveness check via the `ping` method (`MCP::Client#ping`) +- Tool listing via the `tools/list` method (`MCP::Client#tools`) +- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) +- Resource listing via the `resources/list` method (`MCP::Client#resources`) +- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) +- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) +- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) +- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) +- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completions](/server/completions/) +- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`, + or `on_roots` handlers are registered; see [Multi-Round-Trip Results](/client/multi-round-trip-results/) +- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/) +- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with + the `max_pages` guard; see [Pagination](/client/pagination/) +- Automatic JSON-RPC 2.0 message formatting +- UUID request ID generation + +Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics. +Authorization is handled by the transport layer; see [Authorization](/client/authorization/). + +## Tool Objects + +The client provides a wrapper class for tools returned by the server: + +- `MCP::Client::Tool` - Represents a single tool with its metadata + +This class provides easy access to tool properties like name, description, input schema, and output schema. diff --git a/docs/_client/lifecycle.md b/docs/_client/lifecycle.md new file mode 100644 index 0000000..3486b97 --- /dev/null +++ b/docs/_client/lifecycle.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Lifecycle +nav_order: 3 +--- + +# Lifecycle + +Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake +on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28. +This page covers `MCP::Client#connect` and how it negotiates between the two. + +## Handshake + +Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +client.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +client.connected? # => true +client.server_info # => cached InitializeResult +``` + +`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. + +This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page. + +By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake +only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it. + +## Lifecycle Negotiation + +`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled +`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discovery/) first and adopts +the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to +the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare +a `mode:` keyword always receive the classic call shape, unchanged. + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe +``` + +Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process) +and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on +the legacy lifecycle. + +Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result, +their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`, +top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`) +on modern. Code that should work against both lifecycles can use the era-independent readers instead: + +```ruby +client.protocol_version # negotiated or adopted version, either lifecycle +client.server_capabilities # capabilities Hash, either lifecycle +client.instructions # instructions text, either lifecycle +client.server_implementation # server name/version; nil when a modern server does not identify itself +``` + +Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded, +the server now serves the modern lifecycle and the automatic negotiation adopted it. +Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. + +## Explicit Discovery + +`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery +that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct +exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and +the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discovery/) page +for the wire shapes. + +```ruby +result = client.discover +result.supported_versions # => ["2026-07-28"] +``` diff --git a/docs/_client/multi-round-trip-results.md b/docs/_client/multi-round-trip-results.md new file mode 100644 index 0000000..7fa994f --- /dev/null +++ b/docs/_client/multi-round-trip-results.md @@ -0,0 +1,60 @@ +--- +layout: default +title: Multi-Round-Trip Results +nav_order: 4 +--- + +# Multi-Round-Trip Results + +MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`, +or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map +and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. + +## Automatic Driving + +The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, +and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` +(10 by default, matching the TypeScript and Python SDKs). + +```ruby +client = MCP::Client.new(transport: transport) +client.connect(capabilities: { elicitation: { form: {} } }) + +client.on_elicitation do |params| + { action: "accept", content: { name: "Alice" } } +end + +# The input_required round trips are driven automatically; this returns the final result. +response = client.call_tool(name: "collect_name", arguments: {}) +``` + +Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds +the client declared. + +## Manual Driving + +Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; +the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords: + +```ruby +begin + client.call_tool(name: "collect_name", arguments: {}) +rescue MCP::Client::InputRequiredError => error + answers = error.input_requests.transform_values { |request| answer_for(request) } + + client.call_tool( + name: "collect_name", + arguments: {}, + input_responses: answers, + request_state: error.request_state, + ) +end +``` + +`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. +Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged. + +## Server Side + +Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment +shim that serves pre-2026 clients are documented on the server [Multi-Round-Trip Results](/server/multi-round-trip-results/) page. diff --git a/docs/_client/pagination.md b/docs/_client/pagination.md new file mode 100644 index 0000000..1f89c02 --- /dev/null +++ b/docs/_client/pagination.md @@ -0,0 +1,80 @@ +--- +layout: default +title: Pagination +nav_order: 7 +--- + +# Pagination + +Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses +per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination). +Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +## Iterating Pages + +`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. +**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection. +The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor +as method accessors; a `meta` accessor also mirrors the response's `_meta` field: + +```ruby +client = MCP::Client.new(transport: transport) + +cursor = nil +loop do + page = client.list_tools(cursor: cursor) + page.tools.each { |tool| process(tool) } + cursor = page.next_cursor + break unless cursor +end +``` + +The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and +`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page. + +Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration: + +| Server `page_size` | `client.list_tools(cursor: nil)` | +|--------------------|---------------------------------------------------------------------| +| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. | +| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. | + +If your application needs the complete collection regardless of how the server is configured, either loop on +`next_cursor` as shown above, or use the whole-collection methods described below. + +## Fetching the Complete Collection + +`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate +through all pages and return a plain array of items, guaranteeing the full collection regardless +of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round +trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor` +it has already sent, and it stops after `max_pages` pages. + +```ruby +tools = client.tools # => Array of every tool on the server. +``` + +`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods +will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that +point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it +if you legitimately expect more pages than that. + +Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need +fine-grained iteration (e.g. to stream-process pages without loading everything into memory). + +## Cache Hints + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`) +and whether shared intermediaries may cache it (`cacheScope`); see +[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted. +On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: + +```ruby +page = client.list_tools +page.ttl_ms # => 60000 (nil when the server sent no hint) +page.cache_scope # => "private" +``` + +## Server Side + +Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page. diff --git a/docs/_client/ping.md b/docs/_client/ping.md new file mode 100644 index 0000000..e01e21b --- /dev/null +++ b/docs/_client/ping.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Ping +nav_order: 6 +--- + +# Ping + +The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping) +allows either side of the connection to verify that the peer is still responsive. + +{: .note } +> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of +> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself +> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle, +> so on a modern connection a conforming server (the Ruby SDK server included) rejects it +> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility. + +`MCP::Client` exposes `ping` to send a ping to the server: + +```ruby +client = MCP::Client.new(transport: transport) +client.ping # => {} on success +``` + +`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. +It raises `MCP::Client::ValidationError` when the response `result` is missing or +is not a Hash (matching the spec requirement that `result` be an object). +Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) +propagate as exceptions raised by the transport layer. + +## Server Side + +How servers answer `ping` requests and ping the client themselves is documented on +the server [Ping](/server/ping/) page. diff --git a/docs/_client/transports.md b/docs/_client/transports.md new file mode 100644 index 0000000..47203d6 --- /dev/null +++ b/docs/_client/transports.md @@ -0,0 +1,261 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with. +This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports, +their sessions and server-to-client request handling, and the interface a custom transport must implement. + +## Stdio Transport Layer + +Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. + +`MCP::Client::Stdio.new` accepts the following keyword arguments: + +| Parameter | Required | Description | +|---|---|---| +| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). | +| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | +| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | +| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | + +Example usage: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools. +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +# Call a specific tool. +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Close the transport when done. +stdio_transport.close +``` + +The stdio transport automatically handles: + +- Spawning the server process with `Open3.popen3` +- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) +- JSON-RPC 2.0 message framing over newline-delimited JSON + +## HTTP Transport Layer + +Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. + +You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: + +```ruby +gem "mcp" +gem "faraday", ">= 2.0" +gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses +``` + +Example usage: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") +client = MCP::Client.new(transport: http_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools +tools = client.tools +tools.each do |tool| + puts <<~TOOL_INFORMATION + Tool: #{tool.name} + Description: #{tool.description} + Input Schema: #{tool.input_schema} + TOOL_INFORMATION +end + +# Call a specific tool +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Call a tool with progress tracking. +response = client.call_tool( + tool: tools.first, + arguments: { count: 10 }, + progress_token: "my-progress-token" +) +``` + +The server sends `notifications/progress` during execution; the bundled transports do not currently +expose these notifications to application code, so the token's effect is visible on the server side. +See the [Progress](/server/progress/) page. + +`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server - +an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from +a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses. + +`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts, +and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread, +so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes). +The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead, +the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin +the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at. + +### Sessions + +After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: + +```ruby +http_transport.session_id # => "abc123..." +http_transport.protocol_version # => "2025-11-25" +``` + +If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. + +To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. + +```ruby +http_transport.close +``` + +These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is +no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead. + +### Server-to-Client Requests (Elicitation) + +Servers can send requests back to the client while one of the client's own requests is in flight - for example, +[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. +Register a handler and advertise the capability on `connect` to respond to them: + +```ruby +client.connect(capabilities: { elicitation: {} }) + +client.on_elicitation do |params| + { + action: "accept", + # Fill fields omitted by the user with the schema's `default` values (SEP-1034) + content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + } +end +``` + +Registering a handler opens a standalone HTTP GET SSE stream on a background thread +([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)), +since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with +a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with +`http_transport.on_server_request("method/name") { |params| ... }`. + +On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same +registered handlers instead drive the requests embedded in `input_required` results, +as documented on [Multi-Round-Trip Results](/client/multi-round-trip-results/). + +### Server-to-Client Requests (Sampling) + +Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), +letting a server leverage the client's model access without its own API keys. + +{: .warning } +> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`. +> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; +> new servers should call LLM provider APIs directly. + +Register a handler and advertise the capability on `connect`: + +```ruby +client.connect(capabilities: { sampling: {} }) + +client.on_sampling do |params| + completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + { + role: "assistant", + content: { type: "text", text: completion.text }, + model: completion.model, + stopReason: "endTurn", + } +end +``` + +For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. +To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: + +```ruby +client.on_sampling do |params| + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + + generate_completion(params) +end +``` + +Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. + +### Custom Headers from Tool Parameters + +On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries +an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route +on the values without parsing bodies. The declarations are learned from `tools/list` responses: +list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values +(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`, +and a `null` or absent argument omits its header. + +Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names, +duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from +`tools/list` results on modern connections, with a warning naming the tool. +Legacy connections are unaffected: nothing is learned, mirrored, or excluded. + +### Customizing the Faraday Connection + +You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. +The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| + faraday.use MyApp::Middleware::HttpRecorder + faraday.adapter :typhoeus +end +``` + +## Custom Transports + +If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: + +```ruby +class CustomTransport + # Sends a JSON-RPC request to the server and returns the raw response. + # + # @param request [Hash] A complete JSON-RPC request object. + # https://www.jsonrpc.org/specification#request_object + # @return [Hash] A hash modeling a JSON-RPC response object. + # https://www.jsonrpc.org/specification#response_object + def send_request(request:) + # Your transport-specific logic here + # - HTTP: POST to endpoint with JSON body + # - WebSocket: Send message over WebSocket + # - stdio: Write to stdout, read from stdin + # - etc. + end +end + +client = MCP::Client.new(transport: CustomTransport.new) +``` + +Custom transports that need to support client-side cancellation have additional requirements; +see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page. diff --git a/docs/_config.yml b/docs/_config.yml index b589e4e..4f101a5 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,18 +2,44 @@ title: "MCP Ruby SDK" description: "The official Ruby SDK for Model Context Protocol servers and clients." remote_theme: just-the-docs/just-the-docs +color_scheme: ruby-light -# Include generated files and directories which may start with underscores -include: - - "_*" +plugins: + - jekyll-redirect-from + +# Sidebar sections: each collection renders as a plain category heading +# with its pages listed beneath it, instead of a clickable parent page. +collections: + server: + permalink: "/server/:name/" + output: true + client: + permalink: "/client/:name/" + output: true + extensions: + permalink: "/extensions/:name/" + output: true + +just_the_docs: + collections: + server: + name: Building Servers + client: + name: Building Clients + extensions: + name: Extensions # Search search_enabled: true -# Footer -gh_edit_link: true -gh_edit_link_text: "Edit this page on GitHub." -gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk" -gh_edit_branch: "main" -gh_edit_source: "docs" -gh_edit_view_mode: "edit" +# Callouts +callouts: + note: + title: Note + color: blue + important: + title: Important + color: yellow + warning: + title: Warning + color: red diff --git a/docs/_extensions/capability-extensions.md b/docs/_extensions/capability-extensions.md new file mode 100644 index 0000000..3415ae8 --- /dev/null +++ b/docs/_extensions/capability-extensions.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Capability Extensions +nav_order: 2 +--- + +# Capability Extensions + +Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. +Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); +values are extension-defined configuration objects, with `{}` meaning "supported with no settings". + +On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_extensions("com.example/feature" => { enabled: true }) + +server = MCP::Server.new(name: "my_server", capabilities: capabilities) +``` + +The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are +readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports). + +On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result, +and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake. +Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations +with envelope-first resolution, on either lifecycle. + +On the client, pass extensions through `connect`: + +```ruby +client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) +``` diff --git a/docs/_extensions/index.md b/docs/_extensions/index.md new file mode 100644 index 0000000..5b52c0f --- /dev/null +++ b/docs/_extensions/index.md @@ -0,0 +1,17 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /extensions/ +--- + +# Extensions + +Extensions add optional functionality on top of the core MCP protocol. +Support for an extension is negotiated per connection: +clients and servers declare the extensions they speak under the `extensions` member of their capabilities, +as described on [Capability Extensions](/extensions/capability-extensions/). + +The extensions this SDK ships support for: + +- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results diff --git a/docs/_extensions/mcp-apps.md b/docs/_extensions/mcp-apps.md new file mode 100644 index 0000000..0c9c668 --- /dev/null +++ b/docs/_extensions/mcp-apps.md @@ -0,0 +1,48 @@ +--- +layout: default +title: MCP Apps +nav_order: 3 +--- + +# MCP Apps + +MCP Apps (SEP-1865) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) that lets a server ship interactive +HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention, +and `MCP::Apps` provides the vocabulary and helpers: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_resources +capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } } + +server = MCP::Server.new( + name: "weather_server", + capabilities: capabilities, + # UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type. + resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], +) + +server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] +end + +# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also +# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec). +server.define_tool( + name: "get_weather", + meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), +) do |server_context:| + # The extension is optional: always return a meaningful text result, and use + # `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content. + MCP::Apps.client_supports?(server_context.client_capabilities) # => true when the host declared the extension + MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) +end +``` + +`MCP::Apps.tool_meta` also accepts `visibility:` (an array of `"model"` / `"app"`) to restrict who sees the tool, +and merges non-destructively into caller-supplied `meta:`. + +Everything else the extension defines (the sandboxed iframe, the `ui/*` `postMessage` bridge, consent for UI-initiated actions) +is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). diff --git a/docs/_includes/footer_custom.html b/docs/_includes/footer_custom.html new file mode 100644 index 0000000..bfd1278 --- /dev/null +++ b/docs/_includes/footer_custom.html @@ -0,0 +1,48 @@ +{%- comment -%} + Previous/Next pager following the sidebar order: top-level pages by + nav_order, then the server, client, and extensions collections. +{%- endcomment -%} +{%- assign top_pages = site.html_pages | where_exp: "item", "item.nav_order" | sort: "nav_order" -%} +{%- assign server_docs = site.server | sort: "nav_order" -%} +{%- assign client_docs = site.client | sort: "nav_order" -%} +{%- assign extensions_docs = site.extensions | sort: "nav_order" -%} +{%- assign ordered_pages = top_pages | concat: server_docs | concat: client_docs | concat: extensions_docs -%} +{%- assign pager_state = "before" -%} +{%- assign prev_page = nil -%} +{%- assign next_page = nil -%} +{%- for item in ordered_pages -%} + {%- if pager_state == "current" -%} + {%- assign next_page = item -%} + {%- assign pager_state = "after" -%} + {%- elsif item.url == page.url -%} + {%- assign pager_state = "current" -%} + {%- elsif pager_state == "before" -%} + {%- assign prev_page = item -%} + {%- endif -%} +{%- endfor -%} +{%- if pager_state != "before" -%} + +{%- endif -%} diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html index 4a919ce..34a88e6 100644 --- a/docs/_includes/head_custom.html +++ b/docs/_includes/head_custom.html @@ -1,3 +1,5 @@ + + + + diff --git a/docs/_sass/color_schemes/ruby-dark.scss b/docs/_sass/color_schemes/ruby-dark.scss new file mode 100644 index 0000000..5bd0ef1 --- /dev/null +++ b/docs/_sass/color_schemes/ruby-dark.scss @@ -0,0 +1,17 @@ +// Dark counterpart of ruby-light: the link color is lightened from +// the #b61d1d accent so it keeps WCAG AA contrast, and the theme's +// purple-tinted dark greys become neutral ones. +@import "./color_schemes/dark"; + +$link-color: #f0776c; +$btn-primary-color: #b61d1d; + +// The dark scheme derives $feedback-color before these overrides apply, +// so it is restated. +$body-background-color: #252525; +$sidebar-color: #252525; +$feedback-color: #1e1e1e; +$search-background-color: #202020; +$table-background-color: #202020; +$base-button-color: #202020; +$border-color: #3f3f3f; diff --git a/docs/_sass/color_schemes/ruby-light.scss b/docs/_sass/color_schemes/ruby-light.scss new file mode 100644 index 0000000..5da81c3 --- /dev/null +++ b/docs/_sass/color_schemes/ruby-light.scss @@ -0,0 +1,8 @@ +// The include imports the light scheme first, so only overrides live here. +$link-color: #990000; +$btn-primary-color: #b61d1d; +$sidebar-color: #fff; + +// The light scheme derives $feedback-color before these overrides apply, +// so it is restated (the hover shade on the white sidebar). +$feedback-color: #f2f2f2; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss index 498de1b..7f89847 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -65,13 +65,85 @@ /* * Applied before the dark stylesheet finishes loading, so browsers that * do not support blocking="render" avoid a light-colored first paint. - * Values match $grey-dk-300 and $grey-lt-300 from the dark color scheme. + * Values match $body-background-color from the ruby-dark color scheme + * and $grey-lt-300 for the text. */ :root[data-theme="dark"] body { - background-color: #27262b; + background-color: #252525; color: #e6e1e8; } :root[data-theme="light"] { color-scheme: light; } + +/* + * Center the sidebar-plus-content block as one unit, splitting the leftover viewport equally + * on both sides. The theme instead widens the sidebar to absorb the whole left overflow, + * and its margin rule lives on `.side-bar + .main`, which a bare `.main` selector cannot override. + */ +$docs-content-width: 64rem; + +@media (min-width: 66.5rem) { + .side-bar { + /* Capitalized Max/Min pass through the Sass compiler as plain CSS. */ + left: Max(0px, calc((100% - #{$nav-width + $docs-content-width}) / 2)); + width: $nav-width; + } + + .side-bar + .main { + max-width: $docs-content-width; + margin-left: Max(#{$nav-width}, calc((100% - #{$nav-width + $docs-content-width}) / 2 + #{$nav-width})); + } + + /* Breathing room between the sidebar and the content. */ + .main-content-wrap { + padding-left: 3rem; + } + + .main-header { + padding-left: 1rem; + } +} + +/* Previous/Next pager in the page footer. */ +.docs-pager { + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.docs-pager-link { + display: flex; + flex-direction: column; + max-width: 48%; + text-decoration: none; +} + +.docs-pager-next { + margin-left: auto; + text-align: right; +} + +.docs-pager-direction { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $body-text-color; + opacity: 0.6; +} + +.docs-pager-title { + font-weight: 600; +} + +/* + * Category headings read as section labels, not separators: no underline, + * spaced apart from the preceding group and letter-spaced instead. + */ +.nav-category { + margin-top: 1.5rem; + letter-spacing: 0.08em; + border-bottom: none; + opacity: 0.65; +} diff --git a/docs/_sass/custom/setup.scss b/docs/_sass/custom/setup.scss new file mode 100644 index 0000000..b34691c --- /dev/null +++ b/docs/_sass/custom/setup.scss @@ -0,0 +1,2 @@ +// Widen the sidebar from the theme's 16.5rem default. +$nav-width: 19rem; diff --git a/docs/_server/cancellation.md b/docs/_server/cancellation.md new file mode 100644 index 0000000..207dfcf --- /dev/null +++ b/docs/_server/cancellation.md @@ -0,0 +1,137 @@ +--- +layout: default +title: Cancellation +nav_order: 13 +--- + +# Cancellation + +The MCP Ruby SDK supports server-side handling of +the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +When a client sends `notifications/cancelled` for an in-flight request, the server stops +processing cooperatively and suppresses the JSON-RPC response for that request. + +Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead, +a `MCP::Cancellation` token is threaded through [`server_context`](/server/server-context/), and long-running tools +poll it to exit early. When a tool returns after cancellation has been observed, +the server suppresses the JSON-RPC response, matching the spec. The `initialize` request +is never cancellable per the spec. + +{: .note } +> Cancellation by notification belongs to the handshake lifecycle, where the session correlates +> `notifications/cancelled` with the in-flight request it targets. Requests of the +> [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are sessionless single POST exchanges, so a separately +> POSTed cancel notification cannot reach them; a modern client abandons a request by closing +> the connection instead. + +## Handlers that Check for Cancellation + +Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, +`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`, +`resources_unsubscribe_handler`, and [`define_custom_method`](/server/custom-methods/) blocks - receives +an `MCP::ServerContext` wired to the in-flight request's cancellation token. +Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise +`MCP::CancelledError` at a safe point: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that supports cancellation" + input_schema(properties: { count: { type: "integer" } }, required: ["count"]) + + def self.call(count:, server_context:) + count.times do |i| + # Exit early if the client has sent `notifications/cancelled`. + break if server_context.cancelled? + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +Alternatively, raise at the next safe point with `raise_if_cancelled!`: + +```ruby +def self.call(count:, server_context:) + count.times do |i| + server_context.raise_if_cancelled! + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) +end +``` + +When a handler observes cancellation (either by returning early with `cancelled?` or +by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and +no JSON-RPC result is sent to the client. + +The same pattern works for other handler types: + +```ruby +# resources/read +server.resources_read_handler do |params, server_context:| + server_context.raise_if_cancelled! + # read the resource +end + +# completion/complete +server.completion_handler do |params, server_context:| + server_context.raise_if_cancelled! + # compute completions +end + +# custom method +server.define_custom_method(method_name: "custom/slow") do |params, server_context:| + server_context.raise_if_cancelled! + # do work +end + +# prompts (via Prompt subclass) +class SlowPrompt < MCP::Prompt + prompt_name "slow_prompt" + + def self.template(args, server_context:) + server_context.raise_if_cancelled! + MCP::Prompt::Result.new(messages: []) + end +end +``` + +Handlers that do not declare a `server_context:` keyword continue to work unchanged - +the opt-in detection only wraps the context when the block signature asks for it. + +## Nested Server-to-Client Requests Are Cancelled Automatically + +When a tool handler is waiting on a nested server-to-client request +(`server_context.create_sampling_message`, `create_form_elicitation`, or +`create_url_elicitation`), cancelling the parent tool call automatically raises +`MCP::CancelledError` from the nested call, so the tool does not need to wrap it +in its own `cancelled?` checks: + +```ruby +def self.call(server_context:) + result = server_context.create_sampling_message(messages: messages, max_tokens: 100) + # If the parent tools/call is cancelled while waiting above, MCP::CancelledError + # is raised here and the tool can let it propagate or clean up as needed. + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) +rescue MCP::CancelledError + # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server + # will still suppress the JSON-RPC response per the MCP spec. + raise +end +``` + +Nested cancellation propagation is supported on `StreamableHTTPTransport` only. +`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested +`server_context.create_sampling_message` inside a tool runs to completion even if +the parent `tools/call` is cancelled. The parent tool itself still observes cancellation +via `server_context.cancelled?` between nested calls. + +## Client Side + +Cancelling a request the client has issued (the `cancellation:` keyword, wire-order guarantees, +and custom transport requirements) is documented on the client [Cancellation](/client/cancellation/) page. diff --git a/docs/_server/completions.md b/docs/_server/completions.md new file mode 100644 index 0000000..170848e --- /dev/null +++ b/docs/_server/completions.md @@ -0,0 +1,52 @@ +--- +layout: default +title: Completions +nav_order: 16 +--- + +# Completions + +MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), +which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. + +To enable completions, declare the `completions` capability and register a handler: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [CodeReviewPrompt], + resource_templates: [FileTemplate], + capabilities: { completions: {} }, +) + +server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value] + + case ref[:type] + when "ref/prompt" + values = case argument[:name] + when "language" + ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } + else + [] + end + { completion: { values: values, hasMore: false } } + when "ref/resource" + { completion: { values: [], hasMore: false } } + end +end +``` + +The handler receives a `params` hash with: + +- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) +- `argument` - The argument being completed (`{ name: "...", value: "..." }`) +- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) + +The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. +The SDK automatically enforces the 100-item limit per the MCP specification. + +The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. +Requests for unknown references return an error. diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md new file mode 100644 index 0000000..0185cb5 --- /dev/null +++ b/docs/_server/configuration.md @@ -0,0 +1,206 @@ +--- +layout: default +title: Configuration +nav_order: 20 +--- + +# Configuration + +The gem can be configured using the `MCP.configure` block: + +```ruby +MCP.configure do |config| + config.exception_reporter = ->(exception, server_context) { + # Your exception reporting logic here + # For example with Bugsnag: + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, server_context) + end + } + + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") + } +end +``` + +or by creating an explicit configuration and passing it into the server. +This is useful for systems where an application hosts more than one MCP server but +they might require different configurations. + +```ruby +configuration = MCP::Configuration.new +configuration.exception_reporter = ->(exception, server_context) { + # Your exception reporting logic here + # For example with Bugsnag: + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, server_context) + end +} + +configuration.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") +} + +server = MCP::Server.new( + # ... all other options + configuration:, +) +``` + +## Exception Reporter + +The exception reporter receives two arguments: + +- `exception`: The Ruby exception object that was raised +- `server_context`: A hash containing contextual information about where the error occurred. + This is not the user-defined [`server_context`](/server/server-context/) passed to `Server.new`. + +The `server_context` hash includes: + +- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) +- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) + +**Signature:** + +```ruby +exception_reporter = ->(exception, server_context) { ... } +``` + +When an exception occurs: + +1. The exception is reported via the configured reporter +2. The client receives a generic JSON-RPC error response (for example, "Internal error calling tool " + for a tool call); the exception's own message is deliberately withheld from clients + +If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. + +## Around Request + +The `around_request` hook wraps request handling, allowing you to execute code before and after each request. +This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. + +The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: + +**Signature:** + +```ruby +around_request = ->(data, &request_handler) { request_handler.call } +``` + +**`data` availability by timing:** + +- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled +- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` +- Not available inside `around_request`: `duration` (added after `around_request` returns) + +{: .note } +> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods +> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled +> (for example, `prompt_name` is not recorded when the prompt is not found). +> `duration` is added after `around_request` returns, so it is not visible from within the hook. + +**Example:** + +```ruby +MCP.configure do |config| + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") + } +end +``` + +### Exposing the User-Defined `server_context` + +`data` omits the user-defined `server_context` by default, because that hash is +application-supplied and may hold values a tracing backend should not receive. +Enable it when you need to tag spans with the request's subject: + +```ruby +MCP.configure do |config| + config.instrument_server_context = true + + config.around_request = ->(data, &request_handler) { + Sentry.set_user(id: data.dig(:server_context, :user_id)) + request_handler.call + } +end +``` + +`data[:server_context]` is the hash passed to `Server.new` - `nil` when the host +set none. It is not the exception reporter's context argument, which describes +where a failure occurred rather than who made the request. + +## Instrumentation Callback (soft-deprecated) + +{: .note } +> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. +> +> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: +> +> ```ruby +> # Before +> config.instrumentation_callback = ->(data) { log(data) } +> +> # After +> config.around_request = ->(data, &request_handler) do +> request_handler.call +> ensure +> log(data) +> end +> ``` +> +> Note that `data[:duration]` is not available inside `around_request`. +> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. + +The instrumentation callback is called after each request finishes, whether successfully or with an error. +It receives a hash with the following possible keys: + +- `method`: (String) The protocol method called (e.g., "ping", "tools/list") +- `tool_name`: (String, optional) The name of the tool called +- `tool_arguments`: (Hash, optional) The arguments passed to the tool +- `prompt_name`: (String, optional) The name of the prompt called +- `resource_uri`: (String, optional) The URI of the resource called +- `error`: (String, optional) Error code if a lookup failed +- `duration`: (Float) Duration of the call in seconds +- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request +- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when + `instrument_server_context` is enabled + +**Signature:** + +```ruby +instrumentation_callback = ->(data) { ... } +``` + +## Server Protocol Version + +The server's protocol version can be overridden using the `protocol_version` keyword argument: + +```ruby +configuration = MCP::Configuration.new(protocol_version: "2024-11-05") +MCP::Server.new(name: "test_server", configuration: configuration) +``` + +If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default. + +This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: + +```ruby +MCP::Configuration.new(protocol_version: nil) +``` + +If an invalid `protocol_version` value is set, an `ArgumentError` is raised. + +The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model, +`2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`; +a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs. +Clients reach `2026-07-28` through [`server/discover`](/server/discovery/) and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. + +Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md new file mode 100644 index 0000000..85dc87a --- /dev/null +++ b/docs/_server/custom-methods.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Custom Methods +nav_order: 21 +--- + +# Custom Methods + +The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Define a custom method that returns a result +server.define_custom_method(method_name: "add") do |params| + params[:a] + params[:b] +end + +# Define a custom notification method (returns nil) +server.define_custom_method(method_name: "notify") do |params| + # Process notification + nil +end +``` + +**Key Features:** + +- Accepts any method name as a string +- Block receives the request parameters as a hash +- Can handle both regular methods (with responses) and notifications +- Prevents overriding existing MCP protocol methods +- Supports instrumentation callbacks for monitoring +- Blocks may opt in to a [`server_context:`](/server/server-context/) keyword like the built-in handlers + (see [Cancellation](/server/cancellation/) for an example) + +**Usage Example:** + +The wire exchange for the custom `add` method defined above. The client sends: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "add", + "params": { "a": 5, "b": 3 } +} +``` + +The server responds: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": 8 +} +``` + +**Error Handling:** + +- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method +- Supports the same [exception reporting and instrumentation](/server/configuration/) as standard methods diff --git a/docs/_server/discovery.md b/docs/_server/discovery.md new file mode 100644 index 0000000..ec9fa18 --- /dev/null +++ b/docs/_server/discovery.md @@ -0,0 +1,61 @@ +--- +layout: default +title: Discovery +nav_order: 3 +--- + +# Discovery + +`server/discover` is the sessionless capability discovery method of MCP 2026-07-28 (SEP-2575). +It responds before `initialize` and without an `Mcp-Session-Id`, so a client learns what a server +offers in a single exchange, without creating a session. + +## The Discovery Result + +The result carries the modern `supportedVersions`, `capabilities`, and `instructions`, together with +the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional +`io.modelcontextprotocol/serverInfo` stamp in the result `_meta`. + +The request takes no parameters: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "server/discover" } +``` + +The server answers with the full discovery result: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "supportedVersions": ["2026-07-28"], + "capabilities": { "tools": { "listChanged": true } }, + "instructions": "Use the tools of this server as a last resort", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "my_server", "version": "1.0.0" } }, + "ttlMs": 0, + "cacheScope": "private", + "resultType": "complete" + } +} +``` + +## The Stateless Modern Lifecycle + +Beyond answering `server/discover`, the server serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope +(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, +and the Streamable HTTP transport serves them on a sessionless single-exchange path. +Calling `server/discover` first is not required: the envelope alone selects the modern lifecycle for a request, +while a client performing the classic `initialize` handshake is served on the handshake lifecycle - +the lifecycle is a per-request property, not a server-wide mode. + +The bundled transports serve `server/discover` and the per-request `_meta` envelope alongside the `initialize` handshake +with no configuration needed. The `protocol_version` pin scopes the handshake only +and does not affect the modern lifecycle; see [Configuration](/server/configuration/) for details. + +## Client Side + +On the client, `MCP::Client#connect` negotiates the lifecycle automatically by default +(probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips +the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` +exposes the raw discovery result. See the client-side [Lifecycle](/client/lifecycle/) page for details. diff --git a/docs/_server/elicitation.md b/docs/_server/elicitation.md new file mode 100644 index 0000000..f559155 --- /dev/null +++ b/docs/_server/elicitation.md @@ -0,0 +1,231 @@ +--- +layout: default +title: Elicitation +nav_order: 9 +--- + +# Elicitation + +The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/latest/client/elicitation), +which allows servers to request additional information from users through the client during tool execution. + +Elicitation is a **server-to-client request**. The server sends a request and blocks until the user responds via the client. + +{: .note } +> Unlike [roots](/server/roots/) and [sampling](/server/sampling/), elicitation carries no SEP-2577 deprecation +> and remains fully available. On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), which forbids server-initiated requests, +> an `elicitation/create` request is embedded in an `input_required` result instead; +> see [Multi-Round-Trip Results](/server/multi-round-trip-results/). + +{: .important } +> 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. + +## Capabilities + +Clients must declare the `elicitation` capability during initialization. The server checks this before sending any elicitation request +and raises a `RuntimeError` if the client does not support it. + +For URL mode support, the client must also declare `elicitation.url` capability. + +## Using Elicitation in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_form_elicitation` on it: + +```ruby +server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your name", + requested_schema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }]) +end +``` + +## Form Mode + +Form mode collects structured data from the user directly through the MCP client: + +```ruby +server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your contact information", + requested_schema: { + type: "object", + properties: { + name: { type: "string", description: "Your full name" }, + email: { type: "string", format: "email", description: "Your email address" }, + }, + required: ["name", "email"], + }, + ) + + text = case result[:action] + when "accept" + "Hello, #{result[:content][:name]} (#{result[:content][:email]})" + when "decline" + "User declined" + when "cancel" + "User cancelled" + end + + MCP::Tool::Response.new([{ type: "text", text: text }]) +end +``` + +The `requested_schema` must be a flat object schema: a top-level `type: "object"` whose `properties` are limited to +primitive types (`string`, `number`, `integer`, `boolean`). Nested objects and arrays are not allowed, which keeps +the schema simple enough for clients to render as a form. Per the MCP specification, the client validates +the user's input against this schema before returning it, so the `content` of an `accept` response matches the requested shape. + +## Default Values and Enums + +Properties may declare a `default` value (SEP-1034), which clients use to pre-fill the form. +String properties may declare `enum` values, optionally with human-readable `enumNames` (SEP-1330), which clients render as a choice list: + +```ruby +server.define_tool(name: "configure_deploy", description: "Configure a deployment") do |server_context:| + result = server_context.create_form_elicitation( + message: "Configure the deployment", + requested_schema: { + type: "object", + properties: { + replicas: { type: "integer", default: 3 }, + verbose: { type: "boolean", default: false }, + environment: { + type: "string", + enum: ["dev", "staging", "prod"], + enumNames: ["Development", "Staging", "Production"], + default: "dev", + }, + }, + required: ["environment"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Deploying to #{result[:content][:environment]}" }]) +end +``` + +## Enum Schemas + +For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per +[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) instead of building +the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select, +plus the legacy `enumNames` form retained for backward compatibility: + +```ruby +size_schema = MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "s", title: "Small" }, + { value: "m", title: "Medium" }, + { value: "l", title: "Large" }, + ], + default: "m", +) + +tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["urgent", "billing", "feedback"], +) + +result = server_context.create_form_elicitation( + message: "Tell us about your order", + requested_schema: { + type: "object", + properties: { + size: size_schema.to_h, + tags: tags_schema.to_h, + }, + required: ["size"], + }, +) +``` + +The available builders are `untitled_single_select`, `titled_single_select`, `untitled_multi_select`, `titled_multi_select`, +and `legacy_titled`. Each accepts optional `default:`, `title:`, and `description:`. + +The same builders produce the `requestedSchema` of an `elicitation/create` request embedded in a SEP-2322 `input_required` result, +which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle: + +```ruby +MCP::Server::InputRequiredResult.new( + input_requests: { + "size" => { + method: "elicitation/create", + params: { + message: "Pick a size", + requestedSchema: { + type: "object", + properties: { size: size_schema.to_h }, + required: ["size"], + }, + }, + }, + }, +) +``` + +## URL Mode + +URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: + +```ruby +server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:| + elicitation_id = SecureRandom.uuid + + result = server_context.create_url_elicitation( + message: "Please authorize access to your GitHub account", + url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}", + elicitation_id: elicitation_id, + ) + + server_context.notify_elicitation_complete(elicitation_id: elicitation_id) + + MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }]) +end +``` + +## URLElicitationRequiredError + +When a tool cannot proceed until an out-of-band elicitation is completed, raise `MCP::Server::URLElicitationRequiredError`. +This returns a JSON-RPC error with code `-32042` to the client: + +```ruby +server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:| + raise MCP::Server::URLElicitationRequiredError.new([ + { + mode: "url", + elicitationId: SecureRandom.uuid, + url: "https://example.com/oauth/authorize", + message: "GitHub authorization is required.", + }, + ]) +end +``` + +## Timeouts + +Every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely. +`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells +the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`, +which is the knob to reach for when a prompt legitimately waits on a person: + +```ruby +server_context.create_form_elicitation( + message: "Approve this deployment?", + requested_schema: { type: "object", properties: { approved: { type: "boolean" } } }, + timeout: 3600, # This one waits up to an hour. +) +``` + +`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering +surfaces as end-of-file rather than as a wait that never ends. + +The same timeout applies to [roots](/server/roots/) and [sampling](/server/sampling/) requests. diff --git a/docs/_server/index.md b/docs/_server/index.md new file mode 100644 index 0000000..d0921a4 --- /dev/null +++ b/docs/_server/index.md @@ -0,0 +1,53 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /server/ +redirect_from: + - /building-servers.html + - /building-servers/ +--- + +# Building an MCP Server + +The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. +It implements the Model Context Protocol specification, handling model context requests and responses. + +## Key Features + +- Implements JSON-RPC 2.0 message handling +- Supports protocol initialization and capability negotiation +- Manages tool registration and invocation +- Supports prompt registration and execution +- Supports resource registration and retrieval +- Supports stdio and Streamable HTTP (including SSE) transports +- Supports notifications for list changes (tools, prompts, resources) +- Supports roots (server-to-client filesystem boundary queries) +- Supports sampling (server-to-client LLM completion requests) +- Supports cursor-based pagination for list operations +- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) + +## Supported Methods + +- `initialize` - Initializes the protocol and returns server capabilities +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the server's capabilities + before `initialize` and without an `Mcp-Session-Id`, and anchors the stateless modern lifecycle; see [Discovery](/server/discovery/) +- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET + listening stream; see [Notification Subscriptions](/server/notification-subscriptions/) +- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask + the client for additional input instead of performing a server-initiated request; see [Multi-Round-Trip Results](/server/multi-round-trip-results/) +- `ping` - Simple health check +- `logging/setLevel` - Configures the minimum log level for the server +- `tools/list` - Lists all registered tools and their schemas +- `tools/call` - Invokes a specific tool with provided arguments +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves a specific prompt by name +- `resources/list` - Lists all registered resources and their schemas +- `resources/read` - Retrieves a specific resource by name +- `resources/templates/list` - Lists all registered resource templates and their schemas +- `resources/subscribe` - Subscribes to updates for a specific resource +- `resources/unsubscribe` - Unsubscribes from updates for a specific resource +- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `roots/list` - Requests filesystem roots from the client (server-to-client) +- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) +- `elicitation/create` - Requests user input from the client (server-to-client) diff --git a/docs/_server/logging.md b/docs/_server/logging.md new file mode 100644 index 0000000..c2f0230 --- /dev/null +++ b/docs/_server/logging.md @@ -0,0 +1,90 @@ +--- +layout: default +title: Logging +nav_order: 17 +--- + +# Logging + +The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +The `notifications/message` notification is used for structured logging between client and server. + +{: .warning } +> MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Use stderr or OpenTelemetry for new servers. + +{: .note } +> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), where `logging/setLevel` does not exist, the level +> comes per request from the `io.modelcontextprotocol/logLevel` `_meta` member: it authorizes +> `notifications/message` for that request only, delivered on the request's own response stream. +> A request without the member (or with an unrecognized level) receives no log messages. + +## Log Levels + +The SDK supports 8 log levels with increasing severity: + +- `debug` - Detailed debugging information +- `info` - General informational messages +- `notice` - Normal but significant events +- `warning` - Warning conditions +- `error` - Error conditions +- `critical` - Critical conditions +- `alert` - Action must be taken immediately +- `emergency` - System is unusable + +## How Logging Works + +1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level +2. **Server Filtering**: The server only sends log messages at the configured level or higher severity +3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client + +For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. + +For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +**Usage Example:** + +The client first configures the level with a `logging/setLevel` request: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": { "level": "info" } } +``` + +The server then emits messages at each severity; only those at or above the configured level are delivered: + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) + +server.notify_log_message( + data: { message: "Application started successfully" }, + level: "info" +) + +server.notify_log_message( + data: { message: "Configuration file not found, using defaults" }, + level: "warning" +) + +server.notify_log_message( + data: { + error: "Database connection failed", + details: { host: "localhost", port: 5432 } + }, + level: "error", + logger: "DatabaseLogger" # Optional logger name +) +``` + +**Key Features:** + +- Server has capability `logging` to send log messages +- Messages are only sent if a transport is configured +- Messages are filtered based on the client's configured log level +- If the log level hasn't been set by the client, no messages will be sent + +## Transport Support + +- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout +- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) diff --git a/docs/_server/multi-round-trip-results.md b/docs/_server/multi-round-trip-results.md new file mode 100644 index 0000000..44a8f0c --- /dev/null +++ b/docs/_server/multi-round-trip-results.md @@ -0,0 +1,85 @@ +--- +layout: default +title: Multi-Round-Trip Results +nav_order: 10 +--- + +# Multi-Round-Trip Results + +The [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) forbids server-initiated requests. Instead, per SEP-2322, a `tools/call`, `prompts/get`, or `resources/read` handler that +opts in to [`server_context:`](/server/server-context/) may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for +additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes): + +```ruby +class GreetingTool < MCP::Tool + description "Greets the user by name" + + def self.call(server_context:, **_args) + response = server_context.input_response("user_name") + + unless response + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{response.dig(:content, :name)}!" }]) + end +end +``` + +## Deterministic Replay + +On the retried request the handler re-runs from the start and reads the answers via +`server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` +(deterministic replay; the server holds no memory between rounds). The server returns `-32021` +when an embedded request needs a client capability the request did not declare. + +## Securing `requestState` + +The echoed `requestState` arrives as client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via +`Server.new(request_state_security:)` to have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, +all transparently to handlers. Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses +the wire exactly as the handler wrote it and protecting it is the handler author's responsibility. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: [GreetingTool], + request_state_security: MCP::Server::RequestStateSecurity.new(key: ENV.fetch("MCP_REQUEST_STATE_KEY")), +) +``` + +## `resultType` Stamping + +SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 server returns. The server stamps `resultType: "complete"` +on all results of requests carrying the modern `_meta` envelope (and on `server/discover` results), while results that already carry +a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. Legacy results stay unstamped, and clients treat an absent +`resultType` as `"complete"` per the spec. + +## Legacy Clients + +Handlers written in the 2026 style serve pre-2026 clients too: when a `tools/call`, `prompts/get`, or `resources/read` handler returns +an `InputRequiredResult` on the legacy wire, the server fulfills it in place of the client's driver. Each `inputRequests` entry is sent +as the equivalent real server-to-client request (`elicitation/create`, `sampling/createMessage`, `roots/list`), associated with +the originating request per SEP-2260; the answers are collected under the same keys, and the handler re-runs with +`server_context.input_responses` populated and the raw `requestState` echoed, the same deterministic replay contract +the modern client driver follows. The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; +`MCP::Server.new(input_required_legacy_shim: false)` restores the strict rejection of `input_required` results on legacy requests. + +## Client Side + +`call_tool`, `get_prompt`, and `read_resource` drive `input_required` results automatically once the matching handlers are registered; +see the client [Multi-Round-Trip Results](/client/multi-round-trip-results/) page. diff --git a/docs/_server/notification-subscriptions.md b/docs/_server/notification-subscriptions.md new file mode 100644 index 0000000..33e2317 --- /dev/null +++ b/docs/_server/notification-subscriptions.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Notification Subscriptions +nav_order: 12 +--- + +# Notification Subscriptions + +`subscriptions/listen` is the long-lived notification subscription stream of MCP 2026-07-28 (SEP-2575), +replacing the legacy HTTP GET listening stream. + +## Subscribing + +The client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), +the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, +and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. +The honored subset follows the capabilities the server declares (`listChanged` and `subscribe` flags). + +The client opens the stream with a `subscriptions/listen` request: + +```json +{ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "my_client", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +``` + +The first SSE event on the stream is the acknowledgement: + +```json +{ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { "io.modelcontextprotocol/subscriptionId": "listen-1" } + } +} +``` + +## Transport Support + +The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. + +## Limits and Keepalives + +Concurrent streams are capped by `max_listen_subscriptions:` (default 1000; pass `nil` to remove the cap), and each stream receives an SSE keepalive +comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` +when an upstream proxy already keeps the stream alive. + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + max_listen_subscriptions: 500, + listen_keepalive_interval: 30, +) +``` + +A stream stays open until the client closes the connection; on graceful shutdown via `transport.close`, +each open stream receives its `subscriptions/listen` response before closing. + +See [Notifications](/server/notifications/) for the notification types themselves and their session scoping. diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md new file mode 100644 index 0000000..9c87fb8 --- /dev/null +++ b/docs/_server/notifications.md @@ -0,0 +1,68 @@ +--- +layout: default +title: Notifications +nav_order: 11 +--- + +# Notifications + +The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. + +## Notification Methods + +The server provides the following notification methods: + +- `notify_tools_list_changed` - Send a notification when the tools list changes +- `notify_prompts_list_changed` - Send a notification when the prompts list changes +- `notify_resources_list_changed` - Send a notification when the resources list changes +- `notify_log_message` - Send a structured logging notification message (see [Logging](/server/logging/)) + +## Session Scoping + +When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: + +- **`report_progress`** and **`notify_log_message`** called via [`server_context`](/server/server-context/) inside a tool handler are automatically sent only to the requesting client. +No extra configuration is needed. +- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, +as they represent server-wide state changes. These should be called on the `server` instance directly. + +On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; +see [Notification Subscriptions](/server/notification-subscriptions/). + +## Notification Format + +Notifications follow the JSON-RPC 2.0 specification and use these method names: + +- `notifications/tools/list_changed` +- `notifications/prompts/list_changed` +- `notifications/resources/list_changed` +- `notifications/cancelled` (see [Cancellation](/server/cancellation/)) +- `notifications/progress` (see [Progress](/server/progress/)) +- `notifications/message` (see [Logging](/server/logging/)) + +## Broadcasting a List Change + +Call the matching `notify_*` method after changing a server-wide list: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Default Streamable HTTP - session oriented +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +# When tools change, notify clients +server.define_tool(name: "new_tool") { |**args| MCP::Tool::Response.new([{ type: "text", text: "ok" }]) } +server.notify_tools_list_changed + +# When prompts change, notify clients +server.define_prompt(name: "new_prompt") do |args, server_context:| + MCP::Prompt::Result.new(messages: []) +end +server.notify_prompts_list_changed + +# When resources change, notify clients +server.define_resource(uri: "resource://new", name: "new_resource", mime_type: "text/plain") do + [MCP::Resource::TextContents.new(uri: "resource://new", mime_type: "text/plain", text: "contents")] +end +server.notify_resources_list_changed +``` diff --git a/docs/_server/pagination.md b/docs/_server/pagination.md new file mode 100644 index 0000000..c721f18 --- /dev/null +++ b/docs/_server/pagination.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Pagination +nav_order: 18 +--- + +# Pagination + +The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination) +for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, +treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`, +including lists produced by a custom `resources_list_handler`. + +## Enabling Pagination + +Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), +list responses contain all items in a single response, preserving the pre-pagination behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + page_size: 50, +) +``` + +When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { "name": "example_tool" } + ], + "nextCursor": "50" + } +} +``` + +Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. + +## List Result Caching + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; +`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). + +Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "private"`, +the scope that keeps a potentially user-dependent result out of shared caches). +When neither is set, responses are serialized exactly as before. +The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern `_meta` envelope +the server always emits them, filling unset values with the same defaults; legacy protocol versions keep the opt-in behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + ttl_ms: 60_000, # results stay fresh for one minute + cache_scope: "private", # only the requesting client may cache them +) +``` + +A `resources_read_handler` can override the hints per result by returning a full result hash instead of bare contents: + +```ruby +server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } +end +``` + +## Client Side + +Iterating pages, fetching whole collections with the `max_pages` guard, and reading the cache hints +on the result structs are documented on the client [Pagination](/client/pagination/) page. diff --git a/docs/_server/ping.md b/docs/_server/ping.md new file mode 100644 index 0000000..c247fef --- /dev/null +++ b/docs/_server/ping.md @@ -0,0 +1,50 @@ +--- +layout: default +title: Ping +nav_order: 15 +--- + +# Ping + +The MCP Ruby SDK supports +the [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), +which allows either side of the connection to verify that the peer is still responsive. +A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. + +{: .note } +> `ping` belongs to the handshake lifecycle: MCP 2026-07-28 removes the method altogether (SEP-2575), +> since requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are single POST exchanges whose connection +> itself signals liveness, leaving nothing to probe between requests. The server answers `ping` on +> the handshake lifecycle only - a modern request naming it is rejected with `-32601` Method not found - +> and calling `ping` on a `server_context` while serving a modern request raises an error. The long-lived +> [`subscriptions/listen`](/server/notification-subscriptions/) stream is kept alive by SSE keepalive +> frames instead. + +Servers respond to incoming `ping` requests automatically - no setup is required. +Any `MCP::Server` instance replies with an empty result. + +Servers can also send `ping` requests to the client via `ServerSession#ping`. +`ping` is exempt from the SEP-2260 association requirement, so it may also be sent outside a handler. +Inside a tool handler that receives [`server_context:`](/server/server-context/), call `ping` on it: + +```ruby +class HealthCheckTool < MCP::Tool + description "Verifies the client is still responsive" + + def self.call(server_context:) + server_context.ping # => {} on success + + MCP::Tool::Response.new([{ type: "text", text: "client is alive" }]) + end +end +``` + +`#ping` raises `MCP::Server::ValidationError` when the client returns a `result` +that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error) +propagate as exceptions raised by the transport layer. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Client Side + +Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page. diff --git a/docs/_server/progress.md b/docs/_server/progress.md new file mode 100644 index 0000000..1a4a8e8 --- /dev/null +++ b/docs/_server/progress.md @@ -0,0 +1,64 @@ +--- +layout: default +title: Progress +nav_order: 14 +--- + +# Progress + +The MCP Ruby SDK supports progress tracking for long-running tool operations, +following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). + +## How Progress Works + +1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool +2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution +3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress + +## Reporting Progress from Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `report_progress` on it. +The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that reports progress during execution" + input_schema( + properties: { + count: { type: "integer" }, + }, + required: ["count"] + ) + + def self.call(count:, server_context:) + count.times do |i| + # Do work here. + server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}") + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +The `server_context.report_progress` method accepts: + +- `progress` (required) - current progress value (numeric) +- `total:` (optional) - total expected value, so clients can display a percentage +- `message:` (optional) - human-readable status message + +`report_progress` is a no-op when the request carried no `progressToken`, and both numeric and +string tokens are supported. + +{: .note } +> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), progress notifications emitted during a request ride +> the request's own SSE response stream. The bundled transport buffers them and flushes after +> the handler returns, so they preserve order but arrive together with the final response rather +> than in real time. + +## Client Side + +Requesting progress is the client's side of the contract: pass `progress_token:` to `MCP::Client#call_tool` +and the token is sent as `_meta.progressToken`, as shown on the client [Transports](/client/transports/) page. +The bundled client transports do not currently expose a callback for observing the incoming `notifications/progress` messages; +the token's effect is visible on the server side only. diff --git a/docs/_server/prompts.md b/docs/_server/prompts.md new file mode 100644 index 0000000..e8f5a48 --- /dev/null +++ b/docs/_server/prompts.md @@ -0,0 +1,194 @@ +--- +layout: default +title: Prompts +nav_order: 5 +--- + +# Prompts + +MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. + +## Defining Prompts + +The `MCP::Prompt` class provides three ways to create prompts. + +### 1. As a class definition with metadata + +Subclass `MCP::Prompt` and declare the metadata with class-level helpers; `template` builds the result: + +```ruby +class MyPrompt < MCP::Prompt + prompt_name "my_prompt" # Optional - defaults to underscored class name + title "My Prompt" + description "This prompt performs specific functionality..." + arguments [ + MCP::Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ] + meta({ version: "1.0", category: "example" }) + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + description: "Response description", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("User message") + ), + MCP::Prompt::Message.new( + role: "assistant", + content: MCP::Content::Text.new(args["message"]) + ) + ] + ) + end + end +end + +prompt = MyPrompt +``` + +### 2. Using the `MCP::Prompt.define` method + +`MCP::Prompt.define` builds a prompt from keyword arguments, with the block as its template: + +```ruby +prompt = MCP::Prompt.define( + name: "my_prompt", + title: "My Prompt", + description: "This prompt performs specific functionality...", + arguments: [ + MCP::Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ], + meta: { version: "1.0", category: "example" } +) do |args, server_context:| + MCP::Prompt::Result.new( + description: "Response description", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("User message") + ), + MCP::Prompt::Message.new( + role: "assistant", + content: MCP::Content::Text.new(args["message"]) + ) + ] + ) +end +``` + +### 3. Using the `MCP::Server#define_prompt` method + +`MCP::Server#define_prompt` registers the prompt directly on a server instance: + +```ruby +server = MCP::Server.new +server.define_prompt( + name: "my_prompt", + description: "This prompt performs specific functionality...", + arguments: [ + Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ], + meta: { version: "1.0", category: "example" } +) do |args, server_context:| + Prompt::Result.new( + description: "Response description", + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User message") + ), + Prompt::Message.new( + role: "assistant", + content: Content::Text.new(args["message"]) + ) + ] + ) +end +``` + +The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, +e.g. around authentication state or user preferences. + +## Key Components + +- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag +- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content +- `MCP::Prompt::Result` - The output of a prompt template containing description and messages +- `MCP::Content::Text` - Text content for messages + +## Registering Prompts + +Register prompts with the MCP server: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, +) +``` + +The server will handle prompt listing and execution through the MCP protocol methods: + +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves and executes a specific prompt with arguments + +## Prompts with Image and Embedded Resource Content + +Prompt messages are not limited to text. The same `MCP::Content` types used in [tool responses](/server/tools/) can be used as message content, +letting a prompt template include images or inline resource contents. Unlike tool responses, the content object is passed directly rather than as a hash; +`MCP::Prompt::Message` serializes it when the prompt result is returned: + +```ruby +class CodeReviewPrompt < MCP::Prompt + prompt_name "code_review" + description "Review a source file with an accompanying diagram" + arguments [ + MCP::Prompt::Argument.new(name: "file_uri", description: "URI of the file to review", required: true), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::EmbeddedResource.new( + MCP::Resource::TextContents.new( + uri: args["file_uri"], + mime_type: "text/x-ruby", + text: read_source(args["file_uri"]), + ), + ), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Image.new(architecture_diagram_base64, "image/png"), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please review the code above, using the diagram for context."), + ), + ], + ) + end + end +end +``` diff --git a/docs/_server/resources.md b/docs/_server/resources.md new file mode 100644 index 0000000..f53548a --- /dev/null +++ b/docs/_server/resources.md @@ -0,0 +1,319 @@ +--- +layout: default +title: Resources +nav_order: 6 +--- + +# Resources + +MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Defining Resources + +Like [tools](/server/tools/) and [prompts](/server/prompts/), resources can be defined in three ways. + +### 1. As a class definition + +Define a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: + +```ruby +class MyResource < MCP::Resource + uri "https://example.com/my_resource" + resource_name "my-resource" + title "My Resource" + description "Lorem ipsum dolor sit amet" + mime_type "text/html" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: "Hello from example resource!" + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resources: [MyResource], +) +``` + +`resources/read` requests are routed automatically: when the requested URI matches a registered +class-based resource, its `contents` method is called. `contents` may return an array of +`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. +Like tools, `contents` can opt in to a [`server_context:`](/server/server-context/) keyword argument to receive per-request context. + +When class-based resources or resource templates are registered and a `resources/read` request +does not match any of them, the server responds with the standard JSON-RPC Invalid Params error +(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164. + +### 2. Using the `MCP::Resource.define` method + +The block implements `contents`: + +```ruby +resource = MCP::Resource.define( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")] +end +``` + +### 3. Using the `MCP::Server#define_resource` method + +`MCP::Server#define_resource` registers the resource directly on a server instance: + +```ruby +server = MCP::Server.new(name: "my_server") +server.define_resource( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")] +end +``` + +Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, +in which case the server only lists them: + +```ruby +resource = MCP::Resource.new( + uri: "https://example.com/my_resource", + name: "my-resource", + title: "My Resource", + description: "Lorem ipsum dolor sit amet", + mime_type: "text/html", +) + +server = MCP::Server.new( + name: "my_server", + resources: [resource], +) +``` + +With plain data resources, the server must register a handler for the `resources/read` method to +retrieve a resource dynamically. + +```ruby +server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: "text/plain", + text: "Hello from example resource! URI: #{params[:uri]}" + }] +end +``` + +otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces +the default `resources/read` handling, including the automatic routing to class-based resources described above. + +To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, +so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array +and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. +A block that declares `server_context:` receives it: + +```ruby +server.resources_list_handler do |params, server_context:| + server_context[:authenticated] ? real_resources : demo_resources +end +``` + +The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset +into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. + +For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. +Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) +carrying the requested URI in the error `data` member: + +```ruby +server.resources_read_handler do |params| + resource = lookup(params[:uri]) + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource + + [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }] +end +``` + +## Reading Binary Resources + +For binary resources, respond with a base64-encoded `blob` field instead of `text`. +The `MCP::Resource::TextContents` and `MCP::Resource::BlobContents` classes build the two contents shapes defined by the spec: + +```ruby +server.resources_read_handler do |params| + case params[:uri] + when "file:///logo.png" + [ + MCP::Resource::BlobContents.new( + uri: params[:uri], + mime_type: "image/png", + data: Base64.strict_encode64(File.binread("logo.png")), + ).to_h, + ] + else + [ + MCP::Resource::TextContents.new( + uri: params[:uri], + mime_type: "text/plain", + text: "Hello from example resource!", + ).to_h, + ] + end +end +``` + +## Resource Templates + +Resource templates follow the same pattern. Class-based templates declare a `uri_template` and +receive the variables extracted from the requested URI as keyword arguments to `contents`: + +```ruby +class UserProfileTemplate < MCP::ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user-profile" + title "User Profile" + description "Profile data for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: mime_type, + text: { id: user_id }.to_json + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resource_templates: [UserProfileTemplate], +) +``` + +A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`. +An exact match against a registered resource takes precedence over template matching. +`contents` can also opt in to a `server_context:` keyword argument. + +URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: + +- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. +- A variable matches one or more characters excluding `/`. +- Extracted values are not percent-decoded. + +The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, +mirroring the resource variants: + +```ruby +server.define_resource_template( + uri_template: "users://{user_id}/profile", + name: "user-profile", + mime_type: "application/json", +) do |user_id:| + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: "application/json", + text: { id: user_id }.to_json + )] +end +``` + +Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`, +in which case reads must be served by a `resources_read_handler`: + +```ruby +resource_template = MCP::ResourceTemplate.new( + uri_template: "https://example.com/my_resource_template", + name: "my-resource-template", + title: "My Resource Template", + description: "Lorem ipsum dolor sit amet", + mime_type: "text/html", +) + +server = MCP::Server.new( + name: "my_server", + resource_templates: [resource_template], +) +``` + +Registered templates are listed through the `resources/templates/list` protocol method. +To serve reads for URIs that match a template, extract the variable parts of the URI in your `resources_read_handler`: + +```ruby +resource_template = MCP::ResourceTemplate.new( + uri_template: "file:///items/{item_id}", + name: "item", + mime_type: "application/json", +) + +server = MCP::Server.new(name: "my_server", resource_templates: [resource_template]) + +server.resources_read_handler do |params| + if (match = params[:uri].match(%r{\Afile:///items/(?[^/]+)\z})) + [{ + uri: params[:uri], + mimeType: "application/json", + text: { id: match[:item_id] }.to_json, + }] + else + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) + end +end +``` + +## Resource Subscriptions + +Resource subscriptions allow clients to monitor specific resources for changes. +When a subscribed resource is updated, the server sends a notification to the client. + +The SDK does not track subscription state internally. +Server developers register handlers and manage their own subscription state. +Three methods are provided: + +- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests +- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests +- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client + +```ruby +subscribed_uris = Set.new + +server = MCP::Server.new( + name: "my_server", + resources: [my_resource], + capabilities: { resources: { subscribe: true } }, +) + +server.resources_subscribe_handler do |params| + subscribed_uris.add(params[:uri].to_s) +end + +server.resources_unsubscribe_handler do |params| + subscribed_uris.delete(params[:uri].to_s) +end + +server.define_tool(name: "update_resource") do |server_context:, **args| + if subscribed_uris.include?("test://my-resource") + server_context.notify_resources_updated(uri: "test://my-resource") + end + MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h]) +end +``` + +The `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows +alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it +returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` +rather than returning it at the top level, which interoperating clients reject: + +```ruby +server.resources_subscribe_handler do |params| + id = subscriptions.create(params[:uri].to_s) + { _meta: { "myapp.example/subscriptionId" => id } } +end +``` diff --git a/docs/_server/roots.md b/docs/_server/roots.md new file mode 100644 index 0000000..a3341c6 --- /dev/null +++ b/docs/_server/roots.md @@ -0,0 +1,82 @@ +--- +layout: default +title: Roots +nav_order: 7 +--- + +# Roots + +The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. +Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available. + +{: .warning } +> MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Prefer tool parameters, resource URIs, server configuration, or environment +> variables for new servers. A client declaring the `roots` capability on a modern connection emits a deprecation warning. + +{: .important } +> 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. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Like [sampling](/server/sampling/), roots listing is initiated by the server +- **Client Capability**: Clients must declare `roots` capability during initialization +- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change + +## Using Roots in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `list_roots` on it. +The request is automatically routed to the correct client session: + +```ruby +class FileSearchTool < MCP::Tool + description "Search files within the client's project roots" + input_schema( + properties: { + query: { type: "string" } + }, + required: ["query"] + ) + + def self.call(query:, server_context:) + roots = server_context.list_roots + root_uris = roots[:roots].map { |root| root[:uri] } + + MCP::Tool::Response.new([{ + type: "text", + text: "Searching in roots: #{root_uris.join(", ")}" + }]) + end +end +``` + +Result contains an array of root objects: + +```ruby +{ + roots: [ + { uri: "file:///home/user/projects/myproject", name: "My Project" }, + { uri: "file:///home/user/repos/backend", name: "Backend Repository" } + ] +} +``` + +## Handling Root Changes + +Register a callback to be notified when the client's roots change: + +```ruby +server.roots_list_changed_handler do + puts "Client's roots have changed, tools will see updated roots on next call." +end +``` + +## Error Handling + +- Raises `RuntimeError` if client does not support `roots` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/sampling.md b/docs/_server/sampling.md new file mode 100644 index 0000000..af37df0 --- /dev/null +++ b/docs/_server/sampling.md @@ -0,0 +1,87 @@ +--- +layout: default +title: Sampling +nav_order: 8 +--- + +# Sampling + +The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. +This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. + +{: .warning } +> MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. New servers should call LLM provider APIs directly. +> A client declaring the `sampling` capability on a modern connection emits a deprecation warning. + +{: .important } +> 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. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server +- **Client Capability**: Clients must declare `sampling` capability during initialization +- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability +- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs + +## Using Sampling in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_sampling_message` on it. +The request is automatically routed to the correct client session: + +```ruby +class SummarizeTool < MCP::Tool + description "Summarize text using LLM" + input_schema( + properties: { + text: { type: "string" } + }, + required: ["text"] + ) + + def self.call(text:, server_context:) + result = server_context.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } + ], + max_tokens: 500 + ) + + MCP::Tool::Response.new([{ + type: "text", + text: result[:content][:text] + }]) + end +end + +server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) +``` + +## Parameters + +Required: + +- `messages:` (Array) - Array of message objects with `role` and `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +## Error Handling + +- Raises `RuntimeError` if client does not support `sampling` capability +- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/server-context.md b/docs/_server/server-context.md new file mode 100644 index 0000000..f45f576 --- /dev/null +++ b/docs/_server/server-context.md @@ -0,0 +1,111 @@ +--- +layout: default +title: Server Context +nav_order: 19 +--- + +# Server Context + +The `server_context` is a user-defined hash that is passed into the server instance and made available to [tool](/server/tools/) and [prompt](/server/prompts/) calls. +It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. + +**Type:** + +```ruby +server_context: { [String, Symbol] => Any } +``` + +**Example:** + +```ruby +server = MCP::Server.new( + name: "my_server", + server_context: { user_id: current_user.id, request_id: request.uuid } +) +``` + +This hash is then passed as the `server_context` keyword argument to tool and prompt calls. +Note that the exception reporter does not receive this user-defined hash, and instrumentation +callbacks omit it unless you opt in with `instrument_server_context`. +See the [Configuration](/server/configuration/) page for the arguments they receive. + +## Request-specific `_meta` Parameter + +The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/latest/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. + +{: .note } +> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). +> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it +> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. + +**Access Pattern:** + +When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: + +```ruby +class MyTool < MCP::Tool + def self.call(message:, server_context:) + # Access provider-specific metadata + session_id = server_context.dig(:_meta, :session_id) + request_id = server_context.dig(:_meta, :request_id) + + # Access server's original context + user_id = server_context.dig(:user_id) + + MCP::Tool::Response.new([{ + type: "text", + text: "Processing for user #{user_id} in session #{session_id}" + }]) + end +end +``` + +**Client Request Example:** + +A `tools/call` request carrying `_meta`, as read by the tool above: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "my_tool", + "arguments": { "message": "Hello" }, + "_meta": { + "session_id": "abc123", + "request_id": "req_456" + } + } +} +``` + +### Distributed Tracing + +Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating +[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through +incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`, +`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values +to your tracing system yourself: + +```ruby +class TracedTool < MCP::Tool + def self.call(message:, server_context:) + traceparent = server_context.dig(:_meta, :traceparent) + # Hand traceparent/tracestate/baggage to your tracing library + # (e.g. the opentelemetry-ruby gems) to continue the caller's trace. + + MCP::Tool::Response.new([{ type: "text", text: "ok" }]) + end +end +``` + +On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) +accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request: + +```ruby +meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + +client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta) +client.read_resource(uri: "file:///report.txt", meta: meta) +``` diff --git a/docs/_server/tools.md b/docs/_server/tools.md new file mode 100644 index 0000000..0ebf6fe --- /dev/null +++ b/docs/_server/tools.md @@ -0,0 +1,419 @@ +--- +layout: default +title: Tools +nav_order: 4 +--- + +# Tools + +MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. + +## Defining Tools + +This gem provides a `MCP::Tool` class that can be used to create tools in three ways. + +### 1. As a class definition + +Subclass `MCP::Tool` and declare the metadata with class-level helpers; `call` implements the tool: + +```ruby +class MyTool < MCP::Tool + title "My Tool" + description "This tool performs specific functionality..." + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + output_schema( + properties: { + result: { type: "string" }, + success: { type: "boolean" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["result", "success", "timestamp"] + ) + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + title: "My Tool" + ) + + def self.call(message:, server_context:) + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) + end +end + +tool = MyTool +``` + +### 2. Using the `MCP::Tool.define` method + +`MCP::Tool.define` builds a tool from keyword arguments, with the block as its implementation: + +```ruby +tool = MCP::Tool.define( + name: "my_tool", + title: "My Tool", + description: "This tool performs specific functionality...", + annotations: { + read_only_hint: true, + title: "My Tool" + } +) do |args, server_context:| + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +### 3. Using the `MCP::Server#define_tool` method + +`MCP::Server#define_tool` registers the tool directly on a server instance: + +```ruby +server = MCP::Server.new +server.define_tool( + name: "my_tool", + description: "This tool performs specific functionality...", + annotations: { + title: "My Tool", + read_only_hint: true + } +) do |args, server_context:| + Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, +e.g. around authentication state. + +## Tool argument keys + +Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects +and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, +so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. + +This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), +and nested objects must be read with symbol keys: + +```ruby +class ExampleTool < MCP::Tool + description "Echoes a nested argument" + input_schema( + properties: { + message: { type: "string" }, + payload: { + type: "object", + properties: { + subject: { type: "string" }, + } + } + }, + required: ["message"] + ) + + def self.call(message:, payload: nil, server_context:) + subject = payload && payload[:subject] # symbol key, not payload["subject"] + MCP::Tool::Response.new([{ + type: "text", + text: "Message: #{message}; subject: #{subject}" + }]) + end +end +``` + +Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: +Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. + +Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys +that a transport never delivers, so string-key access can pass tests yet fail against a real client. +Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: + +```ruby +delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) +MyTool.call(**delivered, server_context: nil) +``` + +## Tool Annotations + +Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: + +- `destructive_hint`: Indicates if the tool performs destructive operations. Defaults to true +- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false +- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true +- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false +- `title`: A human-readable title for the tool + +Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. + +{: .note } +> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`. + +## Tool Output Schemas + +Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways. + +### 1. Class definition with `output_schema` + +Declare the schema with the `output_schema` class helper, alongside `input_schema`: + +```ruby +class WeatherTool < MCP::Tool + tool_name "get_weather" + description "Get current weather for a location" + + input_schema( + properties: { + location: { type: "string" }, + units: { type: "string", enum: ["celsius", "fahrenheit"] } + }, + required: ["location"] + ) + + output_schema( + properties: { + temperature: { type: "number" }, + condition: { type: "string" }, + humidity: { type: "integer" } + }, + required: ["temperature", "condition", "humidity"] + ) + + def self.call(location:, units: "celsius", server_context:) + # Call weather API and structure the response + api_response = WeatherAPI.fetch(location, units) + weather_data = { + temperature: api_response.temp, + condition: api_response.description, + humidity: api_response.humidity_percent + } + + output_schema.validate_result(weather_data) + + MCP::Tool::Response.new([{ + type: "text", + text: weather_data.to_json + }]) + end +end +``` + +### 2. Using `Tool.define` with `output_schema` + +Pass the schema as the `output_schema:` keyword argument: + +```ruby +tool = MCP::Tool.define( + name: "calculate_stats", + description: "Calculate statistics for a dataset", + input_schema: { + properties: { + numbers: { type: "array", items: { type: "number" } } + }, + required: ["numbers"] + }, + output_schema: { + properties: { + mean: { type: "number" }, + median: { type: "number" }, + count: { type: "integer" } + }, + required: ["mean", "median", "count"] + } +) do |args, server_context:| + # Calculate statistics and validate against schema + MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }]) +end +``` + +### 3. Using `OutputSchema` objects + +Construct an `MCP::Tool::OutputSchema` object explicitly: + +```ruby +class DataTool < MCP::Tool + output_schema MCP::Tool::OutputSchema.new( + properties: { + success: { type: "boolean" }, + data: { type: "object" } + }, + required: ["success"] + ) +end +``` + +Output schema may also describe an array of objects: + +```ruby +class WeatherTool < MCP::Tool + output_schema( + type: "array", + items: { + properties: { + temperature: { type: "number" }, + condition: { type: "string" }, + humidity: { type: "integer" } + }, + required: ["temperature", "condition", "humidity"] + } + ) +end +``` + +Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`, +applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`). + +Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root +(`{ type: "string" }`) or a root-level composition: + +```ruby +class FlexibleTool < MCP::Tool + output_schema( + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "number" } } + ] + ) +end +``` + +Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it +(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to +all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are +capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects; +violations raise `ArgumentError` at construction time. + +MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: + +- **Server Validation**: Servers MUST provide structured results that conform to the output schema +- **Client Validation**: Clients SHOULD validate structured results against the output schema +- **Better Integration**: Enables strict schema validation, type information, and improved developer experience +- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block + +The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. + +By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. +To validate successful tool responses, enable `validate_tool_call_results` on the server [configuration](/server/configuration/): + +```ruby +configuration = MCP::Configuration.new(validate_tool_call_results: true) +server = MCP::Server.new( + name: "example_server", + tools: [WeatherTool], + configuration: configuration +) +``` + +When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. +Error responses are not validated against the output schema. + +## Tool Responses with Structured Content + +Tools can return structured data alongside text content using the `structured_content` parameter. + +The structured content will be included in the JSON-RPC response as the `structuredContent` field. + +Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array) +without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients +that only read `content` still receive the data. + +```ruby +class WeatherTool < MCP::Tool + description "Get current weather and return structured data" + + def self.call(location:, units: "celsius", server_context:) + # Call weather API and structure the response + api_response = WeatherAPI.fetch(location, units) + weather_data = { + temperature: api_response.temp, + condition: api_response.description, + humidity: api_response.humidity_percent + } + + output_schema.validate_result(weather_data) + + MCP::Tool::Response.new( + [{ + type: "text", + text: weather_data.to_json + }], + structured_content: weather_data + ) + end +end +``` + +## Tool Responses with Errors + +Tools can return error information alongside text content using the `error` parameter. + +The error will be included in the JSON-RPC response as the `isError` field. + +```ruby +class WeatherTool < MCP::Tool + description "Get current weather and return structured data" + + def self.call(server_context:) + # Do something here + content = {} + + MCP::Tool::Response.new( + [{ + type: "text", + text: content.to_json + }], + structured_content: content, + error: true + ) + end +end +``` + +## Tool Responses with Image, Audio, and Embedded Resources + +Tool responses are not limited to text. The `MCP::Content` module provides `Image`, `Audio`, and `EmbeddedResource` content types, +which serialize to the `image`, `audio`, and `resource` content blocks defined by the MCP spec. Image and audio data is passed as +a base64-encoded string together with its MIME type: + +```ruby +class ChartTool < MCP::Tool + description "Render a chart as a PNG image" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Text.new("Here is the rendered chart:").to_h, + MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h, + ]) + end +end + +class SpeechTool < MCP::Tool + description "Synthesize speech audio" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h, + ]) + end +end +``` + +An [embedded resource](/server/resources/) wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: + +```ruby +class ReportTool < MCP::Tool + description "Return a report as an embedded resource" + + def self.call(server_context:) + contents = MCP::Resource::TextContents.new( + uri: "report://monthly", + mime_type: "application/json", + text: { total: 42 }.to_json, + ) + + MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h]) + end +end +``` diff --git a/docs/_server/transports.md b/docs/_server/transports.md new file mode 100644 index 0000000..65cf291 --- /dev/null +++ b/docs/_server/transports.md @@ -0,0 +1,274 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +The server ships two transports: [stdio](#stdio-transport) for command-line and desktop integrations, +and [Streamable HTTP](#streamable-http-transport) for web deployments. +This page covers starting each transport, mounting the HTTP transport in Rack and Rails applications, +and its deployment, session, and security settings. + +## Stdio Transport + +If you want to build a local command-line application, you can use the stdio transport: + +```ruby +require "mcp" + +# Create a simple tool +class ExampleTool < MCP::Tool + description "A simple example tool that echoes back its arguments" + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + + class << self + def call(message:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +# Set up the server +server = MCP::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +# Create and start the transport +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open +``` + +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + +You can run this script and then type in requests to the server at the command line. + +```console +$ ruby examples/stdio_server.rb +{"jsonrpc":"2.0","id":"1","method":"ping"} +{"jsonrpc":"2.0","id":"2","method":"tools/list"} +{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} +``` + +## Streamable HTTP Transport + +`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. +The following examples show two common integration styles in Rails. + +{: .important } +> On the legacy handshake lifecycle, `MCP::Server::Transports::StreamableHTTPTransport` stores session and +> SSE stream state in memory, so it must run in a single process. Use a single-process server (e.g., Puma with +> `workers 0`). Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work +> with any configuration, with two caveats: a [`subscriptions/listen`](/server/notification-subscriptions/) stream +> is held in memory by the process that accepted it, so only notifications emitted in that process reach it, +> and the [multi round-trip](/server/multi-round-trip-results/) `requestState` crosses workers only when they +> share a `RequestStateSecurity` key. + +{: .important } +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). +> The check runs before any lifecycle dispatch, so it protects requests of +> the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) as well. + +### Rails (mount) + +`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: + +```ruby +# config/routes.rb +server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], +) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches +`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), +`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per +the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http), +so no additional route configuration is needed. + +A complete runnable application using this approach is available in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). + +### Rails (controller) + +While the mount approach creates a single server at boot time, the controller approach creates a new server per request. +This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). + +`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): + +```ruby +class McpController < ActionController::API + def create + server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, + ) + # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set. + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + status, headers, body = transport.handle_request(request) + + render(json: body.first, status: status, headers: headers) + end +end +``` + +### Stateless Mode + +You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. +This mode allows for easy multi-node deployment. +Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`): + +```ruby +# Stateless Streamable HTTP - session-less +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) +``` + +In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required, +handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server), +and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped +(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. + +{: .note } +> This transport option is distinct from the sessionless [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28. +> The option governs how handshake-lifecycle clients are served; modern requests carry their own `_meta` envelope +> and are served as single exchanges whether or not `stateless: true` is set. + +### JSON Response Mode + +You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. +Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: + +```ruby +# JSON response mode +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true) +``` + +In JSON response mode, the POST response is a single JSON object, so server-to-client messages +that need to arrive during request processing are not supported: +request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests +(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. +Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and +broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream. +This mode is suitable for simple tool servers that do not need server-initiated requests. + +{: .note } +> Like [stateless mode](#stateless-mode), this option applies to handshake-lifecycle clients only. +> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:` +> and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client. + +### Session Limits + +By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted: +they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent +session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it +(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute, +so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots +and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session). + +```ruby +# Tune the limits +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000) + +# Opt out of expiry and/or the cap (not recommended on internet-facing deployments) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil) +``` + +Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. The same holds +for requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), which never create a session; +their long-lived [`subscriptions/listen`](/server/notification-subscriptions/) streams are bounded separately +by `max_listen_subscriptions:`. + +### Session Ownership + +`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session +existence and idle timeout only. It does not bind a session to a user, because the transport never receives +an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, +so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). + +The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` +on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, +so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return +rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded +when the session was created: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, +) +``` + +Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), +it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only +when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. +Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. + +Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, +so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them +(the per-request `Origin` validation of the DNS rebinding protection above still applies); +on that path, authorization is enforced per request by the deploying application. + +### Request Size Limits + +`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory +with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, +and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON +string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only +if you exchange unusually large payloads: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) +``` + +Unlike the deployment options above, these bounds apply to both lifecycles: +requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits. diff --git a/docs/assets/css/just-the-docs-dark.scss b/docs/assets/css/just-the-docs-dark.scss index ac92fb1..20226a0 100644 --- a/docs/assets/css/just-the-docs-dark.scss +++ b/docs/assets/css/just-the-docs-dark.scss @@ -1,3 +1,3 @@ --- --- -{% include css/just-the-docs.scss.liquid color_scheme="dark" %} +{% include css/just-the-docs.scss.liquid color_scheme="ruby-dark" %} diff --git a/docs/assets/css/just-the-docs-light.scss b/docs/assets/css/just-the-docs-light.scss index ac69688..c2a0250 100644 --- a/docs/assets/css/just-the-docs-light.scss +++ b/docs/assets/css/just-the-docs-light.scss @@ -1,3 +1,3 @@ --- --- -{% include css/just-the-docs.scss.liquid color_scheme="light" %} +{% include css/just-the-docs.scss.liquid color_scheme="ruby-light" %} diff --git a/docs/building-clients.md b/docs/building-clients.md deleted file mode 100644 index 6b4c7d0..0000000 --- a/docs/building-clients.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -layout: default -title: Building Clients -nav_order: 4 ---- - -# Building an MCP Client - -The `MCP::Client` class provides an interface for interacting with MCP servers. - -**Supported operations:** - -- Tool listing (`MCP::Client#tools`) and invocation (`MCP::Client#call_tool`) -- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resource`) -- Resource template listing (`MCP::Client#resource_templates`) -- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`) -- Completion requests (`MCP::Client#complete`) - -## Handshake - -Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): - -```ruby -client.connect -# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } - -client.connected? # => true -client.server_info # => cached InitializeResult -``` - -`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. - -This applies to both the Stdio and HTTP transports below. - -## Stdio Transport - -Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses: - -```ruby -stdio_transport = MCP::Client::Stdio.new( - command: "bundle", - args: ["exec", "ruby", "path/to/server.rb"], - env: { "API_KEY" => "my_secret_key" }, - read_timeout: 30 -) -client = MCP::Client.new(transport: stdio_transport) -client.connect - -tools = client.tools -tools.each do |tool| - puts "Tool: #{tool.name} - #{tool.description}" -end - -response = client.call_tool( - tool: tools.first, - arguments: { message: "Hello, world!" } -) - -stdio_transport.close -``` - -| Parameter | Required | Description | -|---|---|---| -| `command:` | Yes | The command to spawn the server process. | -| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | -| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. | -| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. | -| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | - -## HTTP Transport - -Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE (`text/event-stream`) responses: - -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses -``` - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") -client = MCP::Client.new(transport: http_transport) -client.connect - -tools = client.tools -tools.each do |tool| - puts "Tool: #{tool.name} - #{tool.description}" -end - -response = client.call_tool( - tool: tools.first, - arguments: { message: "Hello, world!" } -) -``` - -### Sessions - -After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: - -```ruby -http_transport.session_id # => "abc123..." -http_transport.protocol_version # => "2025-11-25" -``` - -If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. - -To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. - -```ruby -http_transport.close -``` - -### Authorization - -Provide custom headers for authentication: - -```ruby -http_transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) -client = MCP::Client.new(transport: http_transport) -``` - -### Customizing the Faraday Connection - -Pass a block to customize the underlying Faraday connection: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| - faraday.use MyApp::Middleware::HttpRecorder - faraday.adapter :typhoeus -end -``` - -## Custom Transport - -If the built-in transports do not fit your needs, you can implement your own: - -```ruby -class CustomTransport - def send_request(request:) - # Your transport-specific logic here. - # Returns a Hash modeling a JSON-RPC response object. - end -end - -client = MCP::Client.new(transport: CustomTransport.new) -``` - -For more details, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-client). diff --git a/docs/building-servers.md b/docs/building-servers.md deleted file mode 100644 index 408f4be..0000000 --- a/docs/building-servers.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -layout: default -title: Building Servers -nav_order: 3 ---- - -# Building an MCP Server - -The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification. - -## Supported Methods - -- `initialize` - Initializes the protocol and returns server capabilities -- `ping` - Simple health check -- `tools/list` - Lists all registered tools and their schemas -- `tools/call` - Invokes a specific tool with provided arguments -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves a specific prompt by name -- `resources/list` - Lists all registered resources and their schemas -- `resources/read` - Retrieves a specific resource by name -- `resources/templates/list` - Lists all registered resource templates and their schemas -- `resources/subscribe` - Subscribes to updates for a specific resource -- `resources/unsubscribe` - Unsubscribes from updates for a specific resource -- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs -- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) - -## Stdio Transport - -If you want to build a local command-line application, you can use the stdio transport: - -```ruby -require "mcp" - -class ExampleTool < MCP::Tool - description "A simple example tool that echoes back its arguments" - input_schema( - properties: { - message: { type: "string" }, - }, - required: ["message"] - ) - - class << self - def call(message:, server_context:) - MCP::Tool::Response.new([{ - type: "text", - text: "Hello from example tool! Message: #{message}", - }]) - end - end -end - -server = MCP::Server.new( - name: "example_server", - tools: [ExampleTool], -) - -transport = MCP::Server::Transports::StdioTransport.new(server) -transport.open -``` - -`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). - -## Streamable HTTP Transport - -`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. -The following examples show two common integration styles in Rails. - -{: .important } -> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> -> When running multiple server instances behind a load balancer, configure your load balancer to use -> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always -> routed to the same instance. -> -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - -### Rails (mount) - -`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: - -```ruby -# config/routes.rb -server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], -) -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -Rails.application.routes.draw do - mount transport => "/mcp" -end -``` - -`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches -`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), -`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per -the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), -so no additional route configuration is needed. - -### Rails (controller) - -While the mount approach creates a single server at boot time, the controller approach creates a new server per request. -This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). - -`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): - -```ruby -class McpController < ActionController::API - def create - server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, - ) - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) - status, headers, body = transport.handle_request(request) - - render(json: body.first, status: status, headers: headers) - end -end -``` - -## Tools - -Tools provide functionality to LLM applications. There are three ways to define tools: - -### Class Definition - -```ruby -class MyTool < MCP::Tool - title "My Tool" - description "This tool performs specific functionality..." - input_schema( - properties: { - message: { type: "string" }, - }, - required: ["message"] - ) - annotations( - read_only_hint: true, - destructive_hint: false, - ) - - def self.call(message:, server_context:) - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) - end -end -``` - -### Block Definition - -```ruby -tool = MCP::Tool.define( - name: "my_tool", - description: "This tool performs specific functionality...", -) do |args, server_context:| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -### Server-level Definition - -```ruby -server = MCP::Server.new -server.define_tool( - name: "my_tool", - description: "This tool performs specific functionality...", -) do |args, server_context:| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -### Tool argument keys - -Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects -and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, -so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. - -This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), -and nested objects must be read with symbol keys: - -```ruby -class ExampleTool < MCP::Tool - description "Echoes a nested argument" - input_schema( - properties: { - message: { type: "string" }, - payload: { - type: "object", - properties: { - subject: { type: "string" }, - } - } - }, - required: ["message"] - ) - - def self.call(message:, payload: nil, server_context:) - subject = payload && payload[:subject] # symbol key, not payload["subject"] - MCP::Tool::Response.new([{ - type: "text", - text: "Message: #{message}; subject: #{subject}" - }]) - end -end -``` - -Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: -Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. - -Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys -that a transport never delivers, so string-key access can pass tests yet fail against a real client. -Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: - -```ruby -delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) -MyTool.call(**delivered, server_context: nil) -``` - -## Prompts - -Prompts are templates for LLM interactions. Like tools, they can be defined in three ways: - -### Class Definition - -```ruby -class CodeReviewPrompt < MCP::Prompt - prompt_name "code_review" - description "Review code for best practices" - arguments [ - MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), - ] - - class << self - def template(args, server_context:) - MCP::Prompt::Result.new( - description: "Code review", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") - ), - ] - ) - end - end -end -``` - -### Server-level Definition - -```ruby -server.define_prompt( - name: "code_review", - description: "Review code for best practices", - arguments: [ - MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), - ] -) do |args, server_context:| - MCP::Prompt::Result.new( - description: "Code review", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") - ), - ] - ) -end -``` - -## Resources - -Resources provide data access to LLM applications: - -```ruby -class MyResource < MCP::Resource - uri "file:///data/config.json" - resource_name "config" - description "Application configuration" - mime_type "application/json" - - class << self - def contents - [MCP::Resource::TextContents.new( - uri: uri, - mime_type: mime_type, - text: File.read("config.json") - )] - end - end -end - -server = MCP::Server.new( - name: "my_server", - resources: [MyResource] -) -``` - -The server automatically routes `resources/read` requests to the matching class-based resource's `contents` method. -Requests for unregistered URIs respond with the JSON-RPC Invalid Params error (`-32602`). To handle reads manually instead, -register a block with `server.resources_read_handler`, which fully replaces the automatic routing. - -## Configuration - -```ruby -MCP.configure do |config| - config.exception_reporter = ->(exception, server_context) { - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end - } - - config.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" - } -end -``` - -For more details on sampling, notifications, progress tracking, completions, logging, and advanced features, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-server). diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..819f4c1 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,48 @@ +--- +layout: default +title: Examples +nav_order: 3 +permalink: /examples/ +--- + +# Examples + +Runnable examples live in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples) in the repository. + +## Standalone Scripts + +- [`stdio_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_server.rb) - a stdio server for desktop applications and command-line tools +- [`stdio_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_client.rb) - a client that spawns the stdio server as a subprocess and exercises its tools, prompts, and resources +- [`http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_server.rb) - a Rack-based Streamable HTTP server with session management and SSE support +- [`http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_client.rb) - a client driving the HTTP server through all MCP protocol methods +- [`streamable_http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rb) - an SSE-focused server with tools that trigger notifications and progress updates +- [`streamable_http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_client.rb) - an interactive, menu-driven client for testing the SSE stream + +Each script is standalone and run from the repository root: + +```console +$ ruby examples/stdio_server.rb +``` + +See [`examples/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/README.md) for per-example usage and the requests each one demonstrates. + +## Rails Application + +[`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails) is a complete, minimal +Rails application serving an MCP server over the Streamable HTTP transport. The server and transport are built once +at boot and mounted at `/mcp` in `config/routes.rb`; tools live in `app/tools/`, and a text resource is served through +a `resources_read_handler`. + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is then available at `http://localhost:9292/mcp`. See +[`examples/rails/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/rails/README.md) +for a step-by-step cURL walkthrough of the session handshake and tool calls. + +The mount pattern this application uses is explained in [Rails (mount)](/server/transports/#rails-mount) +on the Transports page, alongside [Rails (controller)](/server/transports/#rails-controller), an alternative +that builds a server per request so tools and configuration can vary by request. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..a280d7f --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 564b5f0..79b95ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,23 +4,30 @@ title: Introduction nav_order: 1 --- +# MCP Ruby SDK + The official Ruby SDK for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), implementing both server and client functionality for JSON-RPC 2.0 based communication between LLM applications and context providers. -**Key features:** +## Features -- JSON-RPC 2.0 message handling with protocol initialization and capability negotiation -- Tool, prompt, and resource registration and invocation -- Stdio and Streamable HTTP (including SSE) transports -- Client support for communicating with MCP servers -- Notifications, sampling, progress tracking, and completions +- Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination ## Quick Start -Here is a minimal MCP server using the stdio transport: +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. + +### MCP Server + +A minimal server defines a tool and serves it over the stdio transport: ```ruby require "mcp" +# Create a simple tool class ExampleTool < MCP::Tool description "A simple example tool that echoes back its arguments" input_schema( @@ -40,25 +47,72 @@ class ExampleTool < MCP::Tool end end +# Set up the server server = MCP::Server.new( name: "example_server", tools: [ExampleTool], ) +# Create and start the transport transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` -Run the script and send JSON-RPC requests via stdin: +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: ```console $ ruby server.rb -{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"example","version":"0.1.0"}}} +{"jsonrpc":"2.0","id":"1","method":"ping"} {"jsonrpc":"2.0","id":"2","method":"tools/list"} {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -For comprehensive documentation, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md). +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](/server/transports/). + +### MCP Client + +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools. +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +# Call a specific tool. +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Close the transport when done. +stdio_transport.close +``` + +The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](/client/transports/). + +For comprehensive documentation, see: + +- [Installation](/installation/) - installing the gem and optional feature dependencies +- [Examples](/examples/) - runnable example scripts and a complete Rails application +- [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation +- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip results, notifications, protocol utilities, and configuration +- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip results, and OAuth 2.1 authorization +- [Extensions](/extensions/) - capability extensions and MCP Apps ## API Documentation @@ -74,4 +128,4 @@ Full API reference is hosted on [RubyDoc.info](https://rubydoc.info/gems/mcp). S ## License -This project is transitioning from the MIT License to the Apache License 2.0. See [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) for details. +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/docs/installation.md b/docs/installation.md index 0822994..f93c6e5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,14 +2,25 @@ layout: default title: Installation nav_order: 2 +permalink: /installation/ +redirect_from: + - /installation.html --- # Installation +Install the gem and any optional dependencies for the features you use. + +## Requirements + +Ruby 2.7.0 or later. + +## Installing the Gem + Add this line to your application's Gemfile: ```ruby -gem 'mcp' +gem "mcp" ``` And then execute: @@ -27,6 +38,6 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: ```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' +gem "mcp" +gem "faraday", ">= 2.0" ``` diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000..735a8ed --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Protocol Versions +nav_order: 4 +permalink: /protocol-versions/ +--- + +# Protocol Versions + +The SDK supports the following MCP protocol versions: + +| Version | Era | Notes | +|--------------|-----------|-------------------------------------------------------------------------------------------------| +| `2026-07-28` | Modern | Stateless lifecycle: no handshake, version carried on every request (SEP-2575) | +| `2025-11-25` | Handshake | The default handshake version; adds URL mode elicitation, enum schemas, and sampling tools | +| `2025-06-18` | Handshake | Adds elicitation, structured tool output, and OAuth resource servers; removes JSON-RPC batching | +| `2025-03-26` | Handshake | Adds Streamable HTTP, OAuth 2.1 authorization, tool annotations, and audio content | +| `2024-11-05` | Handshake | Initial protocol revision | + +## The Era Model + +Per the SEP-2575 era model, an era is a property of the protocol version itself: + +- **The modern version** (`2026-07-28`) has no handshake at all: clients discover the server through + [`server/discover`](/server/discovery/), and every request carries its version in the `_meta` envelope, + validated per request. +- **Handshake versions** (`2025-11-25` and earlier) establish a session through the `initialize` handshake. + The server offers `2025-11-25` by default, and the version can be pinned with `MCP::Configuration.new(protocol_version:)`; + see [Server Protocol Version](/server/configuration/#server-protocol-version). + +Pinning the server's handshake version: + +```ruby +configuration = MCP::Configuration.new(protocol_version: "2025-06-18") +MCP::Server.new(name: "my_server", configuration: configuration) +``` + +The handshake never negotiates a modern version: a client asking `initialize` for `2026-07-28` is counter-offered +the latest handshake version, matching the TypeScript and Python SDKs. The bundled transports serve both eras +side by side with no configuration needed. + +## Client Negotiation + +`MCP::Client#connect` negotiates the lifecycle automatically by default: it probes `server/discover` and adopts +the modern lifecycle when the server serves it, falling back to the `initialize` handshake otherwise. +`connect(mode: :modern)`, `connect(mode: :legacy)`, and an explicit `protocol_version:` pin select a lifecycle +directly; see [Lifecycle](/client/lifecycle/). + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # pin the handshake version, no probe +``` + +## Deprecations + +The `2026-07-28` revision deprecates [Roots](/server/roots/), [Sampling](/server/sampling/), and +[Logging](/server/logging/) per SEP-2577; all three remain fully supported on the handshake versions. + +Check the [MCP specification](https://modelcontextprotocol.io/specification/versioning) to understand what each +protocol version includes. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 89c43e0..051a892 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1578,7 +1578,7 @@ module MCP # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. - # See docs/building-servers.md ("Tool argument keys"). + # See docs/server/tools.md ("Tool argument keys"). args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) From 833e21a959a58f1fb4c69c1058793c5480cb9135 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 22 Aug 2026 01:04:43 +0900 Subject: [PATCH 215/247] Release 1.3.0 This release adds a `resources_list_handler` so `resources/list` can be served per-connection, passes handler-returned `_meta` through subscription results, and moves the user-facing documentation from README.md to the documentation site at https://ruby.sdk.modelcontextprotocol.io. Two entries under "Changed" are incompatible with 1.2.0 and ship in a minor release under the exceptions in VERSIONING.md: OAuth endpoint responses larger than 4 MiB are refused, and a request whose id is already in flight on the same session is answered with Invalid Request. IMPORTANT: The documentation moves with #523, and the site deploys only through the release workflow, so #523 needs to be merged immediately before this release. --- CHANGELOG.md | 17 +++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1466a4..a4ee071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-22 + +User-facing documentation now lives on the documentation site at https://ruby.sdk.modelcontextprotocol.io, +and README.md keeps the quick start. Two entries under "Changed" reject traffic that earlier releases accepted +and ship in a minor release under the exceptions described in [VERSIONING.md](VERSIONING.md). + +### Added + +- Add a `resources_list_handler` for context-dependent resource lists (#509) +- Pass a handler-returned `_meta` through the subscribe result (#510) + +### Changed + +- Bound OAuth response bodies in the client (#520) +- Reject duplicate in-flight JSON-RPC request ids (#521) +- Move the documentation from README.md to the documentation site (#523) + ## [1.2.0] - 2026-08-15 This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 401d7fe..237012a 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.2.0" + VERSION = "1.3.0" end From c9d36b04bf77c7c5739b7d0a6c30d732194051b4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 22 Aug 2026 17:47:58 +0900 Subject: [PATCH 216/247] [Doc] Align the Docs Page Names With the MCP Specification Three pages carried names the 2026-07-28 specification does not use: "Multi-Round-Trip Results" for the pattern the specification titles Multi Round-Trip Requests (MRTR) and serves at basic/patterns/mrtr - the results are the `InputRequiredResult` wire type, not the feature - plus "Completions" for the utility the specification titles Completion, and "Notification Subscriptions" for the pattern it titles Subscriptions. Retitle the three pages to the specification's names, move them to /server/mrtr/ and /client/mrtr/, /server/completion/, and /server/subscriptions/ following the specification's slugs, update every internal reference, and keep the URLs published with v1.3.0 working via jekyll-redirect-from. The Discovery page keeps its title but moves from /server/discovery/ to /server/discover/, matching the specification's slug and the `server/discover` RPC, with its published URL redirected the same way. --- README.md | 2 +- docs/_client/index.md | 4 ++-- docs/_client/lifecycle.md | 4 ++-- .../{multi-round-trip-results.md => mrtr.md} | 8 +++++--- docs/_client/transports.md | 2 +- docs/_extensions/capability-extensions.md | 2 +- docs/_server/cancellation.md | 2 +- .../_server/{completions.md => completion.md} | 6 ++++-- docs/_server/configuration.md | 2 +- docs/_server/{discovery.md => discover.md} | 2 ++ docs/_server/elicitation.md | 4 ++-- docs/_server/index.md | 6 +++--- docs/_server/logging.md | 2 +- .../{multi-round-trip-results.md => mrtr.md} | 10 ++++++---- docs/_server/notifications.md | 4 ++-- docs/_server/ping.md | 4 ++-- docs/_server/progress.md | 2 +- ...tion-subscriptions.md => subscriptions.md} | 6 ++++-- docs/_server/transports.md | 20 +++++++++---------- docs/index.md | 6 +++--- docs/protocol-versions.md | 2 +- 21 files changed, 55 insertions(+), 45 deletions(-) rename docs/_client/{multi-round-trip-results.md => mrtr.md} (93%) rename docs/_server/{completions.md => completion.md} (95%) rename docs/_server/{discovery.md => discover.md} (98%) rename docs/_server/{multi-round-trip-results.md => mrtr.md} (90%) rename docs/_server/{notification-subscriptions.md => subscriptions.md} (96%) diff --git a/README.md b/README.md index 45823ab..7a71fb2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io. - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host - Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration -- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination +- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination ## Installation diff --git a/docs/_client/index.md b/docs/_client/index.md index 1f8e541..c919622 100644 --- a/docs/_client/index.md +++ b/docs/_client/index.md @@ -25,9 +25,9 @@ This class supports: - Resource reading via the `resources/read` method (`MCP::Client#read_resource`) - Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) - Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) -- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completions](/server/completions/) +- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/) - Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`, - or `on_roots` handlers are registered; see [Multi-Round-Trip Results](/client/multi-round-trip-results/) + or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/) - Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/) - Cursor-based page iteration on the `list_*` methods and whole-collection fetching with the `max_pages` guard; see [Pagination](/client/pagination/) diff --git a/docs/_client/lifecycle.md b/docs/_client/lifecycle.md index 3486b97..632db50 100644 --- a/docs/_client/lifecycle.md +++ b/docs/_client/lifecycle.md @@ -32,7 +32,7 @@ only when the server does not serve the modern lifecycle, or when `mode: :legacy ## Lifecycle Negotiation `MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled -`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discovery/) first and adopts +`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discover/) first and adopts the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare a `mode:` keyword always receive the classic call shape, unchanged. @@ -69,7 +69,7 @@ Pass `mode: :legacy` for an immediate return to the previous behavior, or switch `MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and -the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discovery/) page +the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discover/) page for the wire shapes. ```ruby diff --git a/docs/_client/multi-round-trip-results.md b/docs/_client/mrtr.md similarity index 93% rename from docs/_client/multi-round-trip-results.md rename to docs/_client/mrtr.md index 7fa994f..4a68e5f 100644 --- a/docs/_client/multi-round-trip-results.md +++ b/docs/_client/mrtr.md @@ -1,10 +1,12 @@ --- layout: default -title: Multi-Round-Trip Results +title: Multi Round-Trip Requests nav_order: 4 +redirect_from: + - /client/multi-round-trip-results/ --- -# Multi-Round-Trip Results +# Multi Round-Trip Requests MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`, or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map @@ -57,4 +59,4 @@ Servers on legacy protocol versions never send `resultType`, so existing behavio ## Server Side Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment -shim that serves pre-2026 clients are documented on the server [Multi-Round-Trip Results](/server/multi-round-trip-results/) page. +shim that serves pre-2026 clients are documented on the server [Multi Round-Trip Requests](/server/mrtr/) page. diff --git a/docs/_client/transports.md b/docs/_client/transports.md index 47203d6..1e1aaac 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -166,7 +166,7 @@ a JSON-RPC `-32601` (method not found) error. To handle methods other than `elic On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same registered handlers instead drive the requests embedded in `input_required` results, -as documented on [Multi-Round-Trip Results](/client/multi-round-trip-results/). +as documented on [Multi Round-Trip Requests](/client/mrtr/). ### Server-to-Client Requests (Sampling) diff --git a/docs/_extensions/capability-extensions.md b/docs/_extensions/capability-extensions.md index 3415ae8..ba3f0c0 100644 --- a/docs/_extensions/capability-extensions.md +++ b/docs/_extensions/capability-extensions.md @@ -23,7 +23,7 @@ server = MCP::Server.new(name: "my_server", capabilities: capabilities) The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports). -On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result, +On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result, and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake. Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations with envelope-first resolution, on either lifecycle. diff --git a/docs/_server/cancellation.md b/docs/_server/cancellation.md index 207dfcf..1bc0b17 100644 --- a/docs/_server/cancellation.md +++ b/docs/_server/cancellation.md @@ -20,7 +20,7 @@ is never cancellable per the spec. {: .note } > Cancellation by notification belongs to the handshake lifecycle, where the session correlates > `notifications/cancelled` with the in-flight request it targets. Requests of the -> [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are sessionless single POST exchanges, so a separately +> [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are sessionless single POST exchanges, so a separately > POSTed cancel notification cannot reach them; a modern client abandons a request by closing > the connection instead. diff --git a/docs/_server/completions.md b/docs/_server/completion.md similarity index 95% rename from docs/_server/completions.md rename to docs/_server/completion.md index 170848e..583a4bc 100644 --- a/docs/_server/completions.md +++ b/docs/_server/completion.md @@ -1,10 +1,12 @@ --- layout: default -title: Completions +title: Completion nav_order: 16 +redirect_from: + - /server/completions/ --- -# Completions +# Completion MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md index 0185cb5..9480df8 100644 --- a/docs/_server/configuration.md +++ b/docs/_server/configuration.md @@ -201,6 +201,6 @@ If an invalid `protocol_version` value is set, an `ArgumentError` is raised. The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model, `2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`; a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs. -Clients reach `2026-07-28` through [`server/discover`](/server/discovery/) and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. +Clients reach `2026-07-28` through [`server/discover`](/server/discover/) and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. diff --git a/docs/_server/discovery.md b/docs/_server/discover.md similarity index 98% rename from docs/_server/discovery.md rename to docs/_server/discover.md index ec9fa18..20f8bd6 100644 --- a/docs/_server/discovery.md +++ b/docs/_server/discover.md @@ -2,6 +2,8 @@ layout: default title: Discovery nav_order: 3 +redirect_from: + - /server/discovery/ --- # Discovery diff --git a/docs/_server/elicitation.md b/docs/_server/elicitation.md index f559155..4eea52b 100644 --- a/docs/_server/elicitation.md +++ b/docs/_server/elicitation.md @@ -13,9 +13,9 @@ Elicitation is a **server-to-client request**. The server sends a request and bl {: .note } > Unlike [roots](/server/roots/) and [sampling](/server/sampling/), elicitation carries no SEP-2577 deprecation -> and remains fully available. On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), which forbids server-initiated requests, +> and remains fully available. On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28), which forbids server-initiated requests, > an `elicitation/create` request is embedded in an `input_required` result instead; -> see [Multi-Round-Trip Results](/server/multi-round-trip-results/). +> see [Multi Round-Trip Requests](/server/mrtr/). {: .important } > Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with diff --git a/docs/_server/index.md b/docs/_server/index.md index d0921a4..2872959 100644 --- a/docs/_server/index.md +++ b/docs/_server/index.md @@ -31,11 +31,11 @@ It implements the Model Context Protocol specification, handling model context r - `initialize` - Initializes the protocol and returns server capabilities - `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the server's capabilities - before `initialize` and without an `Mcp-Session-Id`, and anchors the stateless modern lifecycle; see [Discovery](/server/discovery/) + before `initialize` and without an `Mcp-Session-Id`, and anchors the stateless modern lifecycle; see [Discovery](/server/discover/) - `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET - listening stream; see [Notification Subscriptions](/server/notification-subscriptions/) + listening stream; see [Subscriptions](/server/subscriptions/) - Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask - the client for additional input instead of performing a server-initiated request; see [Multi-Round-Trip Results](/server/multi-round-trip-results/) + the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/) - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/docs/_server/logging.md b/docs/_server/logging.md index c2f0230..b861628 100644 --- a/docs/_server/logging.md +++ b/docs/_server/logging.md @@ -15,7 +15,7 @@ The `notifications/message` notification is used for structured logging between > while remaining fully supported under `2025-11-25`. Use stderr or OpenTelemetry for new servers. {: .note } -> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), where `logging/setLevel` does not exist, the level +> On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), where `logging/setLevel` does not exist, the level > comes per request from the `io.modelcontextprotocol/logLevel` `_meta` member: it authorizes > `notifications/message` for that request only, delivered on the request's own response stream. > A request without the member (or with an unrecognized level) receives no log messages. diff --git a/docs/_server/multi-round-trip-results.md b/docs/_server/mrtr.md similarity index 90% rename from docs/_server/multi-round-trip-results.md rename to docs/_server/mrtr.md index 44a8f0c..3113bdf 100644 --- a/docs/_server/multi-round-trip-results.md +++ b/docs/_server/mrtr.md @@ -1,12 +1,14 @@ --- layout: default -title: Multi-Round-Trip Results +title: Multi Round-Trip Requests nav_order: 10 +redirect_from: + - /server/multi-round-trip-results/ --- -# Multi-Round-Trip Results +# Multi Round-Trip Requests -The [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) forbids server-initiated requests. Instead, per SEP-2322, a `tools/call`, `prompts/get`, or `resources/read` handler that +The [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) forbids server-initiated requests. Instead, per SEP-2322, a `tools/call`, `prompts/get`, or `resources/read` handler that opts in to [`server_context:`](/server/server-context/) may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes): @@ -82,4 +84,4 @@ the modern client driver follows. The shim is on by default (matching the TypeSc ## Client Side `call_tool`, `get_prompt`, and `read_resource` drive `input_required` results automatically once the matching handlers are registered; -see the client [Multi-Round-Trip Results](/client/multi-round-trip-results/) page. +see the client [Multi Round-Trip Requests](/client/mrtr/) page. diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md index 9c87fb8..7cf9460 100644 --- a/docs/_server/notifications.md +++ b/docs/_server/notifications.md @@ -26,8 +26,8 @@ No extra configuration is needed. - **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, as they represent server-wide state changes. These should be called on the `server` instance directly. -On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; -see [Notification Subscriptions](/server/notification-subscriptions/). +On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; +see [Subscriptions](/server/subscriptions/). ## Notification Format diff --git a/docs/_server/ping.md b/docs/_server/ping.md index c247fef..6272062 100644 --- a/docs/_server/ping.md +++ b/docs/_server/ping.md @@ -13,11 +13,11 @@ A `ping` request has no parameters, and the receiver MUST respond promptly with {: .note } > `ping` belongs to the handshake lifecycle: MCP 2026-07-28 removes the method altogether (SEP-2575), -> since requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are single POST exchanges whose connection +> since requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are single POST exchanges whose connection > itself signals liveness, leaving nothing to probe between requests. The server answers `ping` on > the handshake lifecycle only - a modern request naming it is rejected with `-32601` Method not found - > and calling `ping` on a `server_context` while serving a modern request raises an error. The long-lived -> [`subscriptions/listen`](/server/notification-subscriptions/) stream is kept alive by SSE keepalive +> [`subscriptions/listen`](/server/subscriptions/) stream is kept alive by SSE keepalive > frames instead. Servers respond to incoming `ping` requests automatically - no setup is required. diff --git a/docs/_server/progress.md b/docs/_server/progress.md index 1a4a8e8..f82645b 100644 --- a/docs/_server/progress.md +++ b/docs/_server/progress.md @@ -51,7 +51,7 @@ The `server_context.report_progress` method accepts: string tokens are supported. {: .note } -> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), progress notifications emitted during a request ride +> On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), progress notifications emitted during a request ride > the request's own SSE response stream. The bundled transport buffers them and flushes after > the handler returns, so they preserve order but arrive together with the final response rather > than in real time. diff --git a/docs/_server/notification-subscriptions.md b/docs/_server/subscriptions.md similarity index 96% rename from docs/_server/notification-subscriptions.md rename to docs/_server/subscriptions.md index 33e2317..03fe614 100644 --- a/docs/_server/notification-subscriptions.md +++ b/docs/_server/subscriptions.md @@ -1,10 +1,12 @@ --- layout: default -title: Notification Subscriptions +title: Subscriptions nav_order: 12 +redirect_from: + - /server/notification-subscriptions/ --- -# Notification Subscriptions +# Subscriptions `subscriptions/listen` is the long-lived notification subscription stream of MCP 2026-07-28 (SEP-2575), replacing the legacy HTTP GET listening stream. diff --git a/docs/_server/transports.md b/docs/_server/transports.md index 65cf291..e46426f 100644 --- a/docs/_server/transports.md +++ b/docs/_server/transports.md @@ -76,10 +76,10 @@ The following examples show two common integration styles in Rails. > routed to the same instance. > > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. -> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work -> with any configuration, with two caveats: a [`subscriptions/listen`](/server/notification-subscriptions/) stream +> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work +> with any configuration, with two caveats: a [`subscriptions/listen`](/server/subscriptions/) stream > is held in memory by the process that accepted it, so only notifications emitted in that process reach it, -> and the [multi round-trip](/server/multi-round-trip-results/) `requestState` crosses workers only when they +> and the [multi round-trip](/server/mrtr/) `requestState` crosses workers only when they > share a `RequestStateSecurity` key. {: .important } @@ -103,7 +103,7 @@ The following examples show two common integration styles in Rails. > so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` > to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). > The check runs before any lifecycle dispatch, so it protects requests of -> the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) as well. +> the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) as well. ### Rails (mount) @@ -179,7 +179,7 @@ and repeated `initialize` requests are permitted. Request-scoped notifications s (there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. {: .note } -> This transport option is distinct from the sessionless [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28. +> This transport option is distinct from the sessionless [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28. > The option governs how handshake-lifecycle clients are served; modern requests carry their own `_meta` envelope > and are served as single exchanges whether or not `stateless: true` is set. @@ -203,7 +203,7 @@ This mode is suitable for simple tool servers that do not need server-initiated {: .note } > Like [stateless mode](#stateless-mode), this option applies to handshake-lifecycle clients only. -> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:` +> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:` > and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client. ### Session Limits @@ -224,8 +224,8 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session ``` Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. The same holds -for requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), which never create a session; -their long-lived [`subscriptions/listen`](/server/notification-subscriptions/) streams are bounded separately +for requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), which never create a session; +their long-lived [`subscriptions/listen`](/server/subscriptions/) streams are bounded separately by `max_listen_subscriptions:`. ### Session Ownership @@ -253,7 +253,7 @@ it also records the `Origin` header at `initialize` and rejects a later request when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. -Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, +Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them (the per-request `Origin` validation of the DNS rebinding protection above still applies); on that path, authorization is enforced per request by the deploying application. @@ -271,4 +271,4 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_req ``` Unlike the deployment options above, these bounds apply to both lifecycles: -requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits. +requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits. diff --git a/docs/index.md b/docs/index.md index 79b95ca..ee275fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ The official Ruby SDK for the [Model Context Protocol](https://modelcontextproto - Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host - Build [MCP clients](/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration -- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination +- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination ## Quick Start @@ -110,8 +110,8 @@ For comprehensive documentation, see: - [Installation](/installation/) - installing the gem and optional feature dependencies - [Examples](/examples/) - runnable example scripts and a complete Rails application - [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation -- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip results, notifications, protocol utilities, and configuration -- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip results, and OAuth 2.1 authorization +- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip requests, notifications, protocol utilities, and configuration +- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip requests, and OAuth 2.1 authorization - [Extensions](/extensions/) - capability extensions and MCP Apps ## API Documentation diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 735a8ed..54e5e53 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -22,7 +22,7 @@ The SDK supports the following MCP protocol versions: Per the SEP-2575 era model, an era is a property of the protocol version itself: - **The modern version** (`2026-07-28`) has no handshake at all: clients discover the server through - [`server/discover`](/server/discovery/), and every request carries its version in the `_meta` envelope, + [`server/discover`](/server/discover/), and every request carries its version in the `_meta` envelope, validated per request. - **Handshake versions** (`2025-11-25` and earlier) establish a session through the `initialize` handshake. The server offers `2025-11-25` by default, and the version can be pinned with `MCP::Configuration.new(protocol_version:)`; From c934b04f48cb98b4b99e97d1c7efd7a6186d3b21 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 23 Aug 2026 01:07:59 +0900 Subject: [PATCH 217/247] [Doc] Align the ROADMAP deprecation wording with the shipped behavior ## Motivation and Context The Deprecated Features paragraph still described the pre-#516 design, claiming the SDK "emits deprecation warnings when they are used with protocol version 2026-07-28 or newer". Since #516 the only deprecation warning is the client-side one, emitted on a modern connect that declares the Roots or Sampling capabilities, exactly as the 1.2.0 CHANGELOG entry states. Logging never warns (`notify_log_message` is the sanctioned SEP-2575 delivery path on that wire), and the server never warns either: `initialize` counter-offers a legacy version, a modern `Configuration` pin is rejected at construction, and the modern lifecycle refuses the deprecated server-to-client request APIs outright rather than serving them with a warning. The paragraph now describes the shipped behavior and says where each feature lives per lifecycle: unchanged on legacy, carried on 2026-07-28 by input requests embedded in `InputRequiredResult` responses (the SEP-2322 multi round-trip requests pattern) and the envelope's `logLevel`. Those carriers are not the SEP-2577 replacements: the spec's deprecated-features registry points migrating implementations outside the protocol (tool parameters or configuration, direct LLM provider APIs, stderr or OpenTelemetry), and the paragraph now says so instead of presenting the modern delivery forms as the replacements. ## How Has This Been Tested? Documentation-only change; proofread against the `ProtocolDeprecations` module header and the CHANGELOG entry that shipped this behavior in 1.2.0, which the 1.3.0 release leaves unchanged. --- ROADMAP.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ff763d..aa58422 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,11 +19,15 @@ which also defines the Semantic Versioning scheme and breaking-change policy. ## Deprecated Features The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577). -These features remain fully supported throughout 1.x, and the SDK emits deprecation warnings when they are used -with protocol version 2026-07-28 or newer. -Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: -whether 2.0.0 removes them depends on how future MCP spec revisions treat these features -and on adoption of their replacements. +These features remain supported throughout 1.x: on the legacy lifecycle they work unchanged +(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms: +roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses +(the SEP-2322 multi round-trip requests pattern) and per-request log delivery through the envelope's `logLevel`. +The replacements SEP-2577 names live outside the protocol: tool parameters or configuration for roots, +direct LLM provider APIs for sampling, and stderr or OpenTelemetry for logging. +The client emits a deprecation warning when a modern connect declares the deprecated Roots or Sampling capabilities. +Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: whether 2.0.0 removes +them depends on how future MCP spec revisions treat these features and on adoption of their replacements. ## Conformance From 8866317e6283ee2b6127831fce38efae409ca336 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 24 Aug 2026 11:19:09 +0900 Subject: [PATCH 218/247] [Doc] Replace the theme attribution in the nav footer The bottom of the sidebar (and the mobile page footer) showed the theme's fallback line "This site uses Just the Docs, a documentation theme for Jekyll", which tells readers nothing about the SDK. Overriding nav_footer_custom.html replaces it with the release the documentation describes, linking the RubyGems page of the version deployed via _data/versions.yml by bin/generate-gh-pages.sh, and with the licensing: the documentation is CC-BY-4.0 per LICENSE, while the code licensing is delegated to LICENSE itself, whose consent-based MIT to Apache-2.0 transition does not fit a footer phrase. Without versions data (local previews of the docs/ source) only the license sentences render. --- docs/_includes/nav_footer_custom.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/_includes/nav_footer_custom.html diff --git a/docs/_includes/nav_footer_custom.html b/docs/_includes/nav_footer_custom.html new file mode 100644 index 0000000..f366cb0 --- /dev/null +++ b/docs/_includes/nav_footer_custom.html @@ -0,0 +1,14 @@ +{%- comment -%} + Replaces the theme's "This site uses Just the Docs" attribution, shown at + the bottom of the sidebar on desktop and in the page footer on mobile. + _data/versions.yml exists only on the deployed gh-pages tree, where + bin/generate-gh-pages.sh generates it; without it (local previews of + the docs/ source) only the license sentence renders. +{%- endcomment -%} +{% if site.data.versions %} +Documentation for mcp {{ site.data.versions | first }}, +licensed CC-BY-4.0. +{% else %} +Documentation licensed CC-BY-4.0. +{% endif %} +See LICENSE for the code. From 5ce308b082b75f8e96b2b7a4931cbf6ecf8bc0b4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 25 Aug 2026 00:29:52 +0900 Subject: [PATCH 219/247] Deliver `subscriptions/listen` notifications only after the acknowledgement ## Motivation and Context Per SEP-2575, the server MUST NOT send any notification on a subscription before `notifications/subscriptions/acknowledged`. The listen stream was registered in the delivery registry before its acknowledgement was written (the write happens outside the lock so a stalled reader cannot block the transport), so a change notification published concurrently from another thread could be written to the stream ahead of the acknowledgement. The entry is now inserted inactive - still reserving the id and the cap slot atomically - and becomes eligible for delivery only after the acknowledgement write has completed. A concurrent notification inside the window skips the inactive entry, which is indistinguishable from the change happening just before the subscription opened. ## How Has This Been Tested? With a new regression test pinning the window (an entry injected in the registered-but-unacknowledged state receives nothing until activation), the full suite, RuboCop, and the conformance suite, all green. ## Breaking Changes None. Activation happens synchronously inside the same response body proc, so an acknowledged stream behaves exactly as before. --- .../transports/streamable_http_transport.rb | 24 +++++++++++++++++-- .../streamable_http_transport_test.rb | 18 ++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8d88977..3768167 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -162,7 +162,7 @@ module MCP @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} - # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575). + # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter, active: boolean }` (SEP-2575). # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, # which is a follow-up. @listen_subscriptions = {} @@ -883,6 +883,12 @@ module MCP # The proc registers the stream and returns, leaving the response open like # the legacy GET stream (`create_sse_body`). + # + # Registration and activation are split on purpose: the entry is inserted inactive + # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock, + # and only then does the entry become eligible for delivery. A concurrent notification between + # the insert and the acknowledgement write skips the inactive entry, + # enforcing the SEP-2575 rule that no notification precedes the acknowledgement. def listen_sse_body(request_id, honored) proc do |stream| rejected = false @@ -891,7 +897,7 @@ module MCP (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) rejected = true else - @listen_subscriptions[request_id] = { stream: stream, filter: honored } + @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false } end end @@ -909,6 +915,7 @@ module MCP begin send_to_stream(stream, acknowledgement) + activate_listen_subscription(request_id) start_listen_keepalive_thread(request_id) rescue *STREAM_WRITE_ERRORS remove_listen_subscription(request_id) @@ -918,6 +925,15 @@ module MCP end end + # Marks a listen subscription eligible for delivery once its acknowledgement write has completed. + # The entry may already be gone when the transport closed concurrently. + def activate_listen_subscription(request_id) + @mutex.synchronize do + subscription = @listen_subscriptions[request_id] + subscription[:active] = true if subscription + end + end + # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped # connection is detected and its slot freed, rather than held until the next fan-out write. # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame) @@ -999,6 +1015,10 @@ module MCP # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths. matched = @mutex.synchronize do @listen_subscriptions.filter_map do |request_id, subscription| + # An inactive entry has not finished writing its acknowledgement yet; + # delivering to it would put a notification ahead of the acknowledgement. + next unless subscription[:active] + hit = if field subscription[:filter][field] else diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 2cc42de..8ba7e0e 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6108,6 +6108,24 @@ module MCP transport.close end + test "notifications are delivered only after the listen acknowledgement" do + # Inject an entry in the registered-but-not-yet-acknowledged state: the window between + # the registry insert and the acknowledgement write, which happens outside the lock. + io = StringIO.new + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { + stream: io, filter: { toolsListChanged: true }, active: false + } + + @server.notify_tools_list_changed + + assert_empty sse_events(io) + + @transport.send(:activate_listen_subscription, "listen-1") + @server.notify_tools_list_changed + + assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] } + end + test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }) From 3da0c10bed62d1bf75e44c26585c929aabc9f5b0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 25 Aug 2026 01:24:36 +0900 Subject: [PATCH 220/247] Make `subscriptions/listen` refusable for hosts that cannot stream ## Motivation and Context The documented Rails controller pattern builds a fresh transport per request and renders `body.first`, but a modern client's `subscriptions/listen` made `handle_request` return the streaming `Proc` body, so exactly the documented configuration answered every notification-stream request with a 500 - quietly, since tool calls and listings kept working. Nothing gated the route: `serves_subscriptions_listen?` was hardcoded `true` and consulted only by `Server#discover` for capability stripping, and the modern path deliberately ignores `stateless:`. `StreamableHTTPTransport.new` now accepts `serve_subscriptions_listen:` (default `true`, behavior unchanged). When `false`, the transport skips the listen interception so the method falls through the dispatcher as unimplemented - the spec's 404 with JSON-RPC `-32601`, in an Array body the controller pattern can render - and `serves_subscriptions_listen?` reflects the setting, so `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags through its existing wiring and the advertisement agrees with the actual behavior. The documented controller example passes the flag and explains why; refusing implicitly in `stateless:` mode was rejected because a stateless transport mounted as a long-lived Rack app serves listen streams correctly (listen delivery deliberately precedes the stateless notification guard). On the default streaming side, the listen body is now a small streaming-body object rather than a bare `Proc`: it still responds to `call` and deliberately not to `each` (Rack would otherwise classify it as enumerable and break the streaming), while `first` raises an error naming `serve_subscriptions_listen: false` and pointing at the transports documentation, so a host that buffers the body the documented way fails with guidance instead of a bare `NoMethodError`. Fixes #531. ## How Has This Been Tested? With the issue's reproduction script (default: a streaming body whose `body.first` raises the guidance; with the flag: 404, `application/json`, and an Array body whose first element is the `-32601` error), new transport tests covering the refusal shape, the capability stripping on `server/discover`, and the guidance raise with the body's streaming classification, the full suite, RuboCop, and the conformance suite, all green; the docs build passes every internal link and anchor check. ## Breaking Changes None. The keyword defaults to the current behavior; refusal is opt-in. --- docs/_server/subscriptions.md | 3 ++ docs/_server/transports.md | 18 ++++++- .../transports/streamable_http_transport.rb | 53 ++++++++++++++++--- .../streamable_http_transport_test.rb | 50 +++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/docs/_server/subscriptions.md b/docs/_server/subscriptions.md index 03fe614..9777d8c 100644 --- a/docs/_server/subscriptions.md +++ b/docs/_server/subscriptions.md @@ -52,6 +52,9 @@ The first SSE event on the stream is the acknowledgement: ## Transport Support The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. +A host that cannot hold an SSE response open (such as the [Rails controller pattern](/server/transports/#rails-controller), +which buffers the body) declines the method with `serve_subscriptions_listen: false`, answering `-32601` and dropping +the `listChanged`/`subscribe` flags from discovery. ## Limits and Keepalives diff --git a/docs/_server/transports.md b/docs/_server/transports.md index e46426f..009c506 100644 --- a/docs/_server/transports.md +++ b/docs/_server/transports.md @@ -153,8 +153,13 @@ class McpController < ActionController::API prompts: [MyPrompt], server_context: { user_id: current_user.id }, ) - # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set. - transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set, + # and since `render` buffers the whole body, `subscriptions/listen` streams are declined. + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + serve_subscriptions_listen: false, + ) status, headers, body = transport.handle_request(request) render(json: body.first, status: status, headers: headers) @@ -162,6 +167,15 @@ class McpController < ActionController::API end ``` +{: .important } +> The controller pattern builds a fresh transport per request and `render` buffers the whole body, +> so it cannot serve the open SSE stream that [`subscriptions/listen`](/server/subscriptions/) needs: +> without `serve_subscriptions_listen: false`, that request returns a streaming `Proc` body +> that `body.first` cannot render, and a modern client's notification stream fails with a 500. +> With the flag, the method answers 404 with JSON-RPC `-32601`, and `server/discover` stops +> advertising the `listChanged`/`subscribe` capability flags, so well-behaved clients do not ask. +> To actually serve listen streams, use the [mount approach](#rails-mount). + ### Stateless Mode You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8d88977..ff7ec22 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -129,6 +129,12 @@ module MCP # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable # when an upstream proxy already keeps the stream alive. + # @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream. + # A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern, + # which builds a fresh transport per request and renders the body) passes `false`: + # the method then answers 404 with JSON-RPC `-32601` like any unimplemented method, + # and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags, + # keeping the advertisement and the actual behavior in agreement. Defaults to `true`. # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. @@ -145,6 +151,7 @@ module MCP max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, + serve_subscriptions_listen: true, server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT ) super(server) @@ -211,6 +218,7 @@ module MCP end @listen_keepalive_interval = listen_keepalive_interval + @serve_subscriptions_listen = serve_subscriptions_listen unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive? raise ArgumentError, "server_to_client_request_timeout must be a positive number" @@ -260,10 +268,12 @@ module MCP handle_request(Rack::Request.new(env)) end - # The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path, - # so `Server#discover` may advertise `listChanged`/`subscribe` capability flags. + # Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575). + # Gates both the route (a refusing transport answers the method as unimplemented) and + # the `listChanged`/`subscribe` capability flags `Server#discover` advertises, + # so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword. def serves_subscriptions_listen? - true + @serve_subscriptions_listen end def handle_request(request) @@ -723,8 +733,13 @@ module MCP return mismatch_error if mismatch_error # `subscriptions/listen` is a long-lived notification stream served at the transport layer; - # it never dispatches through `Server#handle`. - return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN + # it never dispatches through `Server#handle`. A transport constructed with + # `serve_subscriptions_listen: false` skips the interception, so the method falls through + # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot + # serve an open SSE stream needs, instead of a `Proc` body it can never call. + if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen? + return handle_subscriptions_listen(body) + end session = modern_session notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } @@ -881,10 +896,34 @@ module MCP ) end - # The proc registers the stream and returns, leaving the response open like + # The Rack streaming body of a `subscriptions/listen` response. It responds to `call` + # and deliberately not to `each`, so Rack keeps classifying it as a streaming body; + # `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)` + # in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix. + class ListenStreamBody + def initialize(&block) + @block = block + end + + def call(stream) + @block.call(stream) + end + + def first + raise <<~MESSAGE + subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \ + A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \ + so the method is answered as unimplemented instead. + See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns. + MESSAGE + end + end + private_constant :ListenStreamBody + + # The body registers the stream and returns, leaving the response open like # the legacy GET stream (`create_sse_body`). def listen_sse_body(request_id, honored) - proc do |stream| + ListenStreamBody.new do |stream| rejected = false @mutex.synchronize do if @listen_subscriptions.key?(request_id) || diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 2cc42de..4725efa 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6156,6 +6156,56 @@ module MCP transport.close end + test "a buffered read of the listen stream body raises guidance instead of NoMethodError" do + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + error = assert_raises(RuntimeError) { response[2].first } + assert_includes error.message, "serve_subscriptions_listen: false" + + # The cited page is a published-URL contract, like the kwarg name above. + assert_includes error.message, "https://ruby.sdk.modelcontextprotocol.io/server/transports/" + + # Responding to `each` would make Rack classify the body as enumerable and break streaming. + refute_respond_to response[2], :each + end + + test "subscriptions/listen is refused as unimplemented when the transport does not serve it" do + transport = StreamableHTTPTransport.new(@server, serve_subscriptions_listen: false) + + status, headers, body = transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + assert_equal 404, status + assert_equal "application/json", headers["content-type"] + + # The documented Rails controller pattern renders `body.first`, so the refusal must be an Array body, + # never the streaming Proc. + assert_kind_of Array, body + assert_equal(-32601, JSON.parse(body.first).dig("error", "code")) + ensure + transport.close + end + + test "discover stops advertising subscription capabilities when listen is not served" do + server = Server.new( + name: "listen_test", + capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server, serve_subscriptions_listen: false) + + response = transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + capabilities = JSON.parse(response[2].first).dig("result", "capabilities") + + assert_nil capabilities.dig("tools", "listChanged") + assert_nil capabilities.dig("resources", "listChanged") + assert_nil capabilities.dig("resources", "subscribe") + ensure + transport.close + end + test "subscriptions/listen past the concurrent stream cap is rejected with 503" do transport = StreamableHTTPTransport.new(@server, max_listen_subscriptions: 1) open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) From 636e494068334efd2a328004d8da07384a3940be Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 25 Aug 2026 00:34:34 +0900 Subject: [PATCH 221/247] Add a `rake docs:preview` task serving the docs site locally ## Motivation and Context The documentation site under `docs/` had no development command: previewing a change meant hand-assembling a Jekyll environment, since the site only builds for real when a release publishes it through `bin/generate-gh-pages.sh` and the GitHub Pages runtime. `rake docs:preview` now builds and serves the site at http://localhost:4000 (override with `PORT`), with watch-based rebuilds on edit. The preview dependencies live in their own `docs/Gemfile` rather than the gem's Gemfile: `github-pages` mirrors the runtime that builds the released site (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs), which keeps the preview faithful but is far too heavy to impose on the gem itself. The task subshells into that bundle with `Bundler.with_unbundled_env`, the same delegation shape the conformance tasks use for npx. Two support pieces keep the pinned toolchain running on current Ruby. `docs/_preview/taint_shim.rb` restores the taint API as no-ops: Liquid 4.0.3 calls `obj.tainted?` on every variable render, ahead of its own lax-mode check (`liquid/variable.rb:124`), so on Ruby 3.2 and later, where the taint API was removed, the first layout render dies with NoMethodError, while on Ruby 3.1 and earlier the methods still exist as deprecated no-ops and the shim is inert. The shim is loaded only into the preview process via RUBYOPT. The former default gems the pinned Jekyll expects are listed in the docs Gemfile. The task also regenerates `docs/_data/versions.yml` from the version tags, mirroring what `bin/generate-gh-pages.sh` produces at deploy time, so the nav footer shows the released-gem version line exactly as it does on the published site. Build products and the generated data file are ignored. ## How Has This Been Tested? By running the task on a spare port and verifying the served pages, the footer's version line, and that a SIGINT stops it cleanly with exit 0; `git status` stays clean while it runs (all build products ignored). Verified on both Ruby 3.1.5 (shim inert) and Ruby 4.0.6, where a shimless build reproduces the NoMethodError and the task runs green with the shim. RuboCop and the full suite are green. ## Breaking Changes None. The task and its Gemfile are development-only additions; the gem and its dependencies are untouched. --- .gitignore | 4 ++++ AGENTS.md | 1 + Rakefile | 36 ++++++++++++++++++++++++++++++++++++ docs/Gemfile | 15 +++++++++++++++ docs/_preview/taint_shim.rb | 18 ++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 docs/Gemfile create mode 100644 docs/_preview/taint_shim.rb diff --git a/.gitignore b/.gitignore index 9053d96..de8444e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ /spec/reports/ /tmp/ Gemfile.lock +docs/_site/ +docs/.jekyll-cache/ +docs/.jekyll-metadata +docs/_data/versions.yml diff --git a/AGENTS.md b/AGENTS.md index 8af6c4d..5d34462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - `rake rubocop` - Run linter - `rake` - Run tests and linting (default task) - `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md) +- `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem diff --git a/Rakefile b/Rakefile index 36da72c..c795e56 100644 --- a/Rakefile +++ b/Rakefile @@ -51,6 +51,42 @@ task :conformance_server do Conformance::Server.new(**options).start end +namespace :docs do + desc "Serve the documentation site locally at http://localhost:4000 (PORT)" + task :preview do + docs_dir = File.expand_path("docs", __dir__) + generate_docs_versions_data(docs_dir) + + env = { + "BUNDLE_GEMFILE" => File.join(docs_dir, "Gemfile"), + "RUBYOPT" => "-r#{File.join(docs_dir, "_preview", "taint_shim.rb")}", + } + port = ENV.fetch("PORT", "4000") + + Bundler.with_unbundled_env do + system(env, "bundle", "install", "--quiet", chdir: docs_dir, exception: true) + system(env, "bundle", "exec", "jekyll", "serve", "--port", port, chdir: docs_dir, exception: true) + rescue Interrupt + # Ctrl-C is the way to stop the preview, not an error. + end + end +end + +# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from +# the version tags at deploy time, and the preview generates the same data so the nav footer +# shows the released-gem version line. +def generate_docs_versions_data(docs_dir) + versions = %x(git tag --list).split("\n").filter_map { |tag| + tag[/\A[^0-9]*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\z/, 1] + }.sort_by { |version| + Gem::Version.new(version) + }.reverse + return if versions.empty? + + mkdir_p(File.join(docs_dir, "_data")) + File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join) +end + def npx_available?(task_name) return true if system("which", "npx", out: File::NULL, err: File::NULL) diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..eb295d0 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Dependencies for the local docs preview (`rake docs:preview`), kept out of the gem's own Gemfile: +# github-pages mirrors the GitHub Pages runtime that builds the released site +# (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs). +source "https://rubygems.org" + +gem "github-pages", group: :jekyll_plugins +gem "webrick" + +# Former default gems that the Jekyll version pinned by github-pages still requires on Ruby 4.0. +gem "base64" +gem "bigdecimal" +gem "csv" +gem "logger" diff --git a/docs/_preview/taint_shim.rb b/docs/_preview/taint_shim.rb new file mode 100644 index 0000000..9d43470 --- /dev/null +++ b/docs/_preview/taint_shim.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Liquid 4.0.3 (pinned by github-pages) still calls the taint API that Ruby 3.2 removed. +# Restore it as a no-op for the local docs preview only; `rake docs:preview` loads +# this file via `RUBYOPT`, so nothing outside the preview process is affected. +class Object + def tainted? + false + end + + def taint + self + end + + def untaint + self + end +end From 2200cbd2bbf39499e8dec64703b4f2d0a31cbbbb Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 26 Aug 2026 00:24:10 +0900 Subject: [PATCH 222/247] Serialize `subscriptions/listen` writes so the graceful result is the final message ## Motivation and Context On graceful teardown (`transport.close`), each open `subscriptions/listen` stream receives its `SubscriptionsListenResult` response before closing, signaling a clean end the client can distinguish from an abrupt disconnect. But a delivery that snapshotted the stream from the registry before teardown cleared it performs its write outside the lock, so a change notification could land after the final result. Each subscription entry now carries a per-stream write mutex. Teardown marks the entry closed and writes the result under that mutex; a racing delivery either lands before the result or observes `closed` and skips, keeping the graceful result the stream's final message. The mutex is per stream and held only around the single write call, so deliveries to different streams stay as parallel as before, and no path nests it inside the transport mutex (delivery snapshots release `@mutex` first, and the write-error cleanup takes `@mutex` only after the write mutex is released), ruling out lock-order inversions. Keepalive pings stay outside the mutex: they are SSE comment frames, which clients ignore by specification, so one landing after the result is harmless. ## How Has This Been Tested? With a new regression test pinning the race (an entry captured before teardown and re-presented to delivery afterwards writes nothing after the result), the full suite, RuboCop, and the conformance suite, all green. ## Breaking Changes None. The write mutex is internal to the listen registry; the wire only gains the ordering guarantee. --- .../transports/streamable_http_transport.rb | 56 ++++++++++++------- .../streamable_http_transport_test.rb | 21 ++++++- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8f329b5..b406d6d 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -169,7 +169,8 @@ module MCP @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} - # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter, active: boolean }` (SEP-2575). + # Maps a `subscriptions/listen` request id to + # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575). # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, # which is a follow-up. @listen_subscriptions = {} @@ -936,7 +937,7 @@ module MCP (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) rejected = true else - @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false } + @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new } end end @@ -1066,24 +1067,32 @@ module MCP uris.is_a?(Array) && uris.include?(uri) end - [request_id, subscription[:stream]] if hit + [request_id, subscription] if hit end end - matched.each do |request_id, stream| + matched.each do |request_id, subscription| meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id } notification_params = (params || {}).merge(_meta: meta) notification = { jsonrpc: "2.0", method: method, params: notification_params } begin - send_to_stream(stream, notification) + # The per-stream write mutex orders this write against a concurrent graceful teardown: + # once teardown has marked the entry closed and written its `SubscriptionsListenResult`, + # a delivery that snapshotted the entry before the registry was cleared skips it instead + # of writing after the final message. + subscription[:write_mutex].synchronize do + next if subscription[:closed] + + send_to_stream(subscription[:stream], notification) + end rescue *STREAM_WRITE_ERRORS => e MCP.configuration.exception_reporter.call( e, { subscription_id: request_id, error: "Failed to send notification" }, ) remove_listen_subscription(request_id) - close_stream_safely(stream) + close_stream_safely(subscription[:stream]) end end end @@ -1102,20 +1111,27 @@ module MCP end removed.each do |request_id, subscription| - begin - send_to_stream(subscription[:stream], { - jsonrpc: "2.0", - id: request_id, - result: { - # `SubscriptionsListenResult` is served at the transport layer and never - # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is - # stamped at its construction site. - resultType: ResultType::COMPLETE, - _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, - }, - }) - rescue *STREAM_WRITE_ERRORS - nil + # Marking the entry closed and writing the result under the stream's write mutex orders + # this against in-flight deliveries: each one either lands before the result or observes + # `closed` and skips, keeping the graceful result the stream's final message. + subscription[:write_mutex].synchronize do + subscription[:closed] = true + + begin + send_to_stream(subscription[:stream], { + jsonrpc: "2.0", + id: request_id, + result: { + # `SubscriptionsListenResult` is served at the transport layer and never + # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is + # stamped at its construction site. + resultType: ResultType::COMPLETE, + _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, + }, + }) + rescue *STREAM_WRITE_ERRORS + nil + end end close_stream_safely(subscription[:stream]) end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index a7d192b..36fe9ac 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6113,7 +6113,7 @@ module MCP # the registry insert and the acknowledgement write, which happens outside the lock. io = StringIO.new @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { - stream: io, filter: { toolsListChanged: true }, active: false + stream: io, filter: { toolsListChanged: true }, active: false, write_mutex: Mutex.new } @server.notify_tools_list_changed @@ -6126,6 +6126,23 @@ module MCP assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] } end + test "a delivery racing the graceful teardown cannot write after the final result" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + entry = @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] + + @transport.close + + # Simulate an in-flight delivery that snapshotted the entry before teardown cleared + # the registry: the closed flag set under the write mutex makes it a no-op. + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = entry + @server.notify_tools_list_changed + + events = sse_events(io) + + assert_equal "complete", events.last.dig("result", "resultType") + refute(events.any? { |event| event["method"] == "notifications/tools/list_changed" }) + end + test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }) @@ -6264,7 +6281,7 @@ module MCP ping = data end stream.define_singleton_method(:flush) {} - @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { stream: stream, filter: {} } + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { stream: stream, filter: {}, write_mutex: Mutex.new } @transport.send(:send_listen_keepalive_ping, "listen-1") From 50accf644d43ccabe53f752b0feb392599c46344 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 27 Aug 2026 13:17:46 +0900 Subject: [PATCH 223/247] Release 1.4.0 This release repairs the `subscriptions/listen` stream: a host that buffers its responses, the documented Rails controller pattern among them, can decline the method with `serve_subscriptions_listen: false` instead of answering a modern client with a streaming body it cannot render, and the stream's ordering guarantees are enforced - no notification before the acknowledgement, none after the graceful closing result. The new keyword argument is what makes this a minor release; everything else is a bug fix. The controller-pattern crash is a regression from 1.2.0, where the modern lifecycle began answering `subscriptions/listen` that earlier versions had refused as an unknown method, and it fails quietly: only the notification stream breaks, so tool calls keep working. --- CHANGELOG.md | 17 +++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ee071..81a301c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2026-08-28 + +This release repairs the `subscriptions/listen` stream. A host that buffers its responses, +the documented Rails controller pattern among them, can decline the method with +`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body +it cannot render, and the stream's ordering guarantees are enforced: no notification reaches +a client before the acknowledgement, and none follows the graceful closing result. + +### Added + +- Add `serve_subscriptions_listen:` for hosts that cannot hold an SSE stream open (#533) + +### Fixed + +- Deliver `subscriptions/listen` notifications only after the acknowledgement (#532) +- Serialize `subscriptions/listen` writes so the graceful result is the final message (#535) + ## [1.3.0] - 2026-08-22 User-facing documentation now lives on the documentation site at https://ruby.sdk.modelcontextprotocol.io, diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 237012a..8699187 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.3.0" + VERSION = "1.4.0" end From bfc6e551a3a9b738def565ae90fe2814706265bd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 17 Aug 2026 03:07:22 +0900 Subject: [PATCH 224/247] Update examples for the 2026-07-28 era ## Motivation and Context The runnable examples drifted from the SDK they ship with: - POST requests whose `Accept` header does not cover both `application/json` and `text/event-stream` are answered 406, so the cURL walkthroughs no longer worked as printed. - `MCP::Client#connect` now defaults to `mode: :auto`, which adopts the sessionless modern lifecycle (2026-07-28) against a dual-era server; the Streamable HTTP client example requires a session and broke against its own server. - POST responses on an established session arrive as an SSE stream, which the hand-rolled HTTP client did not parse. - `notification_tool` claimed to send SSE notifications but only returned a `Tool::Response`. - `http_client.rb` read a resource URI the server never registered and skipped `notifications/initialized`. Handshake examples now offer the latest handshake protocol version (`2025-11-25`) instead of `2024-11-05`, the Streamable HTTP client pins `mode: :legacy` for its session-based demo, `notification_tool` reports real `notifications/progress` events requested through `_meta.progressToken`, and the interactive client streams those events from the POST response so they are visible on screen. The modern lifecycle itself had no runnable example, so a new server and client pair (`modern_http_server.rb`, `modern_http_client.rb`) shows the sessionless flow end to end: `server/discover` capability discovery, the per-request `_meta` envelope with the `Mcp-Method` and `Mcp-Name` headers (stamped by the SDK client automatically), `resultType` stamping, SEP-2549 cache hints, a SEP-2322 multi round-trip `deploy` tool resumed automatically by the client's elicitation handler, and the removal of legacy-only methods such as `ping`. The same server keeps serving the legacy `initialize` flow, routed per request by the `MCP-Protocol-Version` header. `examples/README.md` documents the pair and adds a cURL walkthrough covering the modern headers, the envelope, and the multi round-trip exchange. ## How Has This Been Tested? - `ruby examples/stdio_client.rb` against `stdio_server.rb` - `ruby examples/http_client.rb` against `http_server.rb` - `ruby examples/streamable_http_client.rb` against `streamable_http_server.rb`, including the progress stream - `ruby examples/modern_http_client.rb` against `modern_http_server.rb` (discover, modern adoption, cache hints, `resultType`, the multi round-trip resume, `ping` rejection) - The cURL walkthroughs in the banners and `examples/README.md`, including the `requestState` echo and a legacy `initialize` against the modern example server ## Breaking Changes None. --- examples/README.md | 112 +++++++++++++++++++++--- examples/http_client.rb | 73 ++++++++++++---- examples/http_server.rb | 5 +- examples/modern_http_client.rb | 133 +++++++++++++++++++++++++++++ examples/modern_http_server.rb | 126 +++++++++++++++++++++++++++ examples/rails/README.md | 2 +- examples/streamable_http_client.rb | 62 ++++++++++++-- examples/streamable_http_server.rb | 39 ++++++--- 8 files changed, 501 insertions(+), 51 deletions(-) create mode 100644 examples/modern_http_client.rb create mode 100644 examples/modern_http_server.rb diff --git a/examples/README.md b/examples/README.md index 86931c5..af1828a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Available Tools:** -- `notification_tool` - Send custom SSE notifications with optional delays +- `notification_tool` - Sends progress notifications over SSE, with optional delays - `echo` - Simple echo tool for basic testing **Usage:** @@ -124,14 +124,15 @@ An interactive client that connects to the SSE stream and provides a menu-driven 1. Start the SSE test server in one terminal: - ```console - $ ruby examples/streamable_http_server.rb - ``` +```console +$ ruby examples/streamable_http_server.rb +``` 2. Run the SSE test client in another terminal: - ```console - $ ruby examples/streamable_http_client.rb - ``` + +```console +$ ruby examples/streamable_http_client.rb +``` The client will: @@ -155,6 +156,34 @@ $ bundle exec puma --port 9292 The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. +### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) + +A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests. + +**Features:** + +- `server/discover` capability discovery before (or instead of) a handshake +- The per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers, stamped by the SDK automatically +- `resultType` stamping and SEP-2549 cache hints (`ttlMs`, `cacheScope`) on results +- A multi round-trip `deploy` tool (SEP-2322), resumed automatically by the client's elicitation handler +- The removal of legacy-only methods such as `ping` + +**Usage:** + +1. Start the server in one terminal: + +```console +$ ruby examples/modern_http_server.rb +``` + +2. Run the client in another terminal: + +```console +$ ruby examples/modern_http_client.rb +``` + +The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header. + ### Testing with MCP Inspector [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers. @@ -188,24 +217,78 @@ You can also test SSE functionality manually using cURL: ```console SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') ``` -2. Connect to SSE stream (in one terminal): +2. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal): ```console curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393 ``` -3. Trigger notifications (in another terminal): +3. Call the notification tool. The `notifications/progress` events (requested via the `progressToken` in `_meta`) and the final response arrive as SSE events on the POST response itself: ```console -# Send immediate notification curl -i http://localhost:9393 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: $SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}' ``` +### Testing the modern lifecycle with cURL + +The modern lifecycle (2026-07-28, SEP-2575) is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. Start `examples/modern_http_server.rb` and walk it manually: + +1. Probe capabilities with `server/discover`: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' +``` + +2. List tools (the `MCP-Protocol-Version` header selects the era, `Mcp-Method` mirrors the method, and `params._meta` carries the SEP-2575 envelope): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/list" \ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +3. Call a tool (name-bearing methods additionally mirror the name in the `Mcp-Name` header): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: greet" \ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +4. Observe a multi round-trip result (SEP-2322), then resume it by echoing the `requestState` back together with the answer. Declaring the `elicitation` capability in the envelope is required before the server may embed elicitation requests: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' + +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"inputResponses":{"environment":{"action":"accept","content":{"environment":"staging"}}},"requestState":"{\"app\":\"storefront\"}","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' +``` + +The same server still accepts the legacy flow from the previous sections, routed by the `MCP-Protocol-Version` header. + ## Streamable HTTP Transport Details ### Protocol Flow @@ -236,13 +319,15 @@ Initialize a session: ```console curl -i http://localhost:9292 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' ``` List tools (using the session ID from initialization): ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/list","id":2}' ``` @@ -251,6 +336,7 @@ Call a tool: ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}' ``` diff --git a/examples/http_client.rb b/examples/http_client.rb index 4ef95c7..b61b563 100644 --- a/examples/http_client.rb +++ b/examples/http_client.rb @@ -4,7 +4,7 @@ require "net/http" require "json" require "uri" -# Simple HTTP client example for interacting with the MCP HTTP server +# Simple HTTP client example for interacting with the MCP HTTP server. class MCPHTTPClient def initialize(base_url = "http://localhost:9292") @base_url = base_url @@ -30,19 +30,51 @@ class MCPHTTPClient response = http.request(request) - # Store session ID if provided + # Store session ID if provided. if response["Mcp-Session-Id"] @session_id = response["Mcp-Session-Id"] puts "Session ID: #{@session_id}" end - JSON.parse(response.body) + parse_response_body(response) + end + + # In the transport's default SSE mode, POST responses on an established session arrive as + # a Server-Sent Events stream whose `data:` line carries the JSON-RPC response; unwrap it + # before parsing. + def parse_response_body(response) + body = response.body + + if response["Content-Type"]&.start_with?("text/event-stream") + data_lines = body.lines.select { |line| line.start_with?("data:") } + body = data_lines.map { |line| line.sub(/\Adata:\s*/, "") }.join + end + + JSON.parse(body) + end + + def send_notification(method, params = nil) + uri = URI(@base_url) + http = Net::HTTP.new(uri.host, uri.port) + + request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path) + request["Content-Type"] = "application/json" + request["Mcp-Session-Id"] = @session_id if @session_id + + # Notifications carry no `id` and receive no JSON-RPC response body. + request.body = { + jsonrpc: "2.0", + method: method, + params: params, + }.compact.to_json + + http.request(request) end def initialize_session puts "=== Initializing session ===" result = send_request("initialize", { - protocolVersion: "2024-11-05", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "example_client", @@ -54,6 +86,14 @@ class MCPHTTPClient result end + def notify_initialized + puts "=== Sending notifications/initialized ===" + response = send_notification("notifications/initialized") + puts "Response status: #{response.code} #{response.message}" + + response + end + def ping puts "=== Sending ping ===" result = send_request("ping") @@ -147,37 +187,40 @@ def main client = MCPHTTPClient.new begin - # Initialize session + # Initialize session. client.initialize_session - # Test ping + # Complete the handshake. + client.notify_initialized + + # Test ping. client.ping - # List available tools + # List available tools. client.list_tools - # Call the example_tool (note: snake_case name) + # Call the example_tool (note: snake_case name). client.call_tool("example_tool", { a: 5, b: 3 }) - # Call the echo tool + # Call the echo tool. client.call_tool("echo", { message: "Hello from client!" }) - # List prompts + # List prompts. client.list_prompts - # Get a prompt (note: snake_case name) + # Get a prompt (note: snake_case name). client.get_prompt("example_prompt", { message: "This is a test message" }) - # List resources + # List resources. client.list_resources - # Read a resource - client.read_resource("test_resource") + # Read a resource (the URI registered by http_server.rb). + client.read_resource("https://test_resource.invalid") rescue => e puts "Error: #{e.message}" puts e.backtrace ensure - # Clean up session + # Clean up session. client.close_session end end diff --git a/examples/http_server.rb b/examples/http_server.rb index 4f3edee..33d8d80 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -56,6 +56,7 @@ end # Set up the server server = MCP::Server.new( name: "example_http_server", + version: "1.0.0", tools: [ExampleTool], prompts: [ExamplePrompt], resources: [ @@ -169,7 +170,9 @@ puts <<~MESSAGE Starting MCP HTTP server on http://localhost:9292 Use POST requests to initialize and send JSON-RPC commands Example initialization: - curl -i http://localhost:9292 --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + curl -i http://localhost:9292 \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' The server will return a session ID in the Mcp-Session-Id header. Use this session ID for subsequent requests. diff --git a/examples/modern_http_client.rb b/examples/modern_http_client.rb new file mode 100644 index 0000000..ca2cd1f --- /dev/null +++ b/examples/modern_http_client.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "json" + +# Client example for the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake +# and per-session state with sessionless, self-contained requests. +class MCPModernHTTPClient + def initialize(base_url = "http://localhost:9494") + @transport = MCP::Client::HTTP.new(url: base_url) + @client = MCP::Client.new(transport: @transport) + end + + # `server/discover` (SEP-2575) is sessionless capability discovery that works before, or instead of, `connect`. + def discover + puts "=== Discovering server capabilities (server/discover) ===" + result = @client.discover + puts <<~MESSAGE + Supported versions: #{result.supported_versions.inspect} + Server: #{result.server_info} + Cache hints: ttlMs=#{result.ttl_ms}, cacheScope=#{result.cache_scope} (SEP-2549) + Instructions: #{result.instructions} + MESSAGE + + result + end + + # The handler answers `elicitation/create` requests embedded in SEP-2322 `input_required` results; once registered, + # `call_tool` resumes those results automatically. + # Register it before `connect` so the connection never needs a server-to-client route. + def register_elicitation_handler + @client.on_elicitation do |params| + puts " Server asked: #{params["message"]}" + + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]) } + end + end + + # `mode: :modern` requires the modern lifecycle and fails on legacy-only servers; + # the default `:auto` probes `server/discover` first and falls back to the legacy `initialize` handshake. + # The `elicitation` capability must be declared before the server may embed elicitation requests. + def connect + puts "=== Adopting the modern lifecycle ===" + result = @client.connect( + client_info: { name: "modern-http-client", version: "1.0" }, + capabilities: { elicitation: {} }, + mode: :modern, + ) + puts <<~MESSAGE + Modern connection: #{@transport.modern?} + Protocol version: #{@client.protocol_version} + MESSAGE + + result + end + + # Modern requests are self-contained: the SDK stamps the `_meta` envelope (protocol version, + # client info, capabilities) and the Mcp-Method and Mcp-Name headers on every request automatically. + def list_tools + puts "=== Listing tools ===" + page = @client.list_tools + page.tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + puts "Cache hints: ttlMs=#{page.ttl_ms}, cacheScope=#{page.cache_scope}" + + page + end + + def call_tool(name, arguments) + puts "=== Calling tool: #{name} ===" + response = @client.call_tool(name: name, arguments: arguments) + puts <<~MESSAGE + resultType: #{response.dig("result", "resultType")} + Response: #{response.dig("result", "content", 0, "text")} + MESSAGE + + response + end + + # The modern lifecycle removed `initialize`, `ping`, `logging/setLevel`, and resource subscriptions (SEP-2575); + # calling one is an error. + def ping + puts "=== Calling removed method: ping ===" + @client.ping + rescue MCP::Client::RequestHandlerError => e + puts "ping was removed by the modern lifecycle (SEP-2575): #{e.message}" + end + + def close + # No session exists on a modern connection; this only clears local state. + @transport.close + end +end + +puts <<~MESSAGE + MCP Modern Lifecycle Client (2026-07-28) + Make sure the server is running (ruby examples/modern_http_server.rb) + #{"=" * 60} +MESSAGE + +client = MCPModernHTTPClient.new + +begin + # Probe the server before adopting a lifecycle. + client.discover + + # Answer the elicitation requests the server embeds in its results. + client.register_elicitation_handler + + # Adopt the modern lifecycle. + client.connect + + # List available tools. + client.list_tools + + # Call a plain single round-trip tool. + client.call_tool("greet", { name: "MCP" }) + + # Call a multi round-trip tool (SEP-2322): the driver fulfills the embedded elicitation via the handler registered above + # and re-issues the call with `inputResponses` plus the echoed `requestState`. Without a handler, `call_tool` raises + # `MCP::Client::InputRequiredError` for manual driving via the `input_responses:` / `request_state:` keywords. + client.call_tool("deploy", { app: "storefront" }) + + # Call a method the modern lifecycle removed. + client.ping +rescue => e + puts <<~MESSAGE + Error: #{e.message} + #{e.backtrace.join("\n")} + MESSAGE +ensure + client.close +end diff --git a/examples/modern_http_server.rb b/examples/modern_http_server.rb new file mode 100644 index 0000000..b21c35d --- /dev/null +++ b/examples/modern_http_server.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "rackup" +require "json" +require "logger" + +# Example server for the 2026-07-28 modern lifecycle (SEP-2575). +# +# The modern lifecycle is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. +# Clients probe the server with `server/discover` and then send self-contained requests that carry +# a `_meta` envelope (protocol version, client info, capabilities) plus `MCP-Protocol-Version` / `Mcp-Method` headers. +# +# The same server still accepts the legacy `initialize` flow: the transport routes each request to +# the legacy or modern lifecycle by its `MCP-Protocol-Version` header, so no constructor flag is needed. +server = MCP::Server.new( + name: "modern_lifecycle_server", + version: "1.0.0", + instructions: "Example server demonstrating the 2026-07-28 modern lifecycle.", + # SEP-2549 cache hints, advertised in `server/discover` and stamped on cacheable results. + # The defaults are 0 / "private"; non-default values are used here so they are visible + # in the example output. + ttl_ms: 60_000, + cache_scope: "public", +) + +server.define_tool( + name: "greet", + description: "Greets a name; a plain single round-trip tool", + input_schema: { properties: { name: { type: "string" } }, required: ["name"] }, +) do |name:| + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }]) +end + +# A multi round-trip tool (SEP-2322). The first round returns an `input_required` result asking +# the client to answer an embedded `elicitation/create` request; the client re-issues the call with +# `inputResponses` plus the echoed opaque `requestState`, and the handler re-runs from the start +# and reads the answer via `server_context`. +# Over a legacy connection the default `input_required_legacy_shim` fulfills the same result through +# a real server-to-client elicitation request instead. +server.define_tool( + name: "deploy", + description: "Deploys an app; asks which environment via a multi round-trip elicitation (SEP-2322)", + input_schema: { properties: { app: { type: "string" } }, required: ["app"] }, +) do |app:, server_context:| + answer = server_context.input_response("environment") + + if answer.nil? + MCP::Server::InputRequiredResult.new( + input_requests: { + "environment" => { + method: "elicitation/create", + params: { + message: "Which environment should #{app} deploy to?", + requestedSchema: { + type: "object", + properties: { + environment: { type: "string", title: "Environment", default: "staging" }, + }, + required: ["environment"], + }, + }, + }, + }, + request_state: JSON.generate(app: app), + ) + else + # Transports parse JSON with `symbolize_names: true`, so answers normally arrive symbol-keyed; + # the string fallback covers custom transports. + action = answer[:action] || answer["action"] + + if action == "accept" + environment = answer.dig(:content, :environment) || answer.dig("content", "environment") + MCP::Tool::Response.new([{ type: "text", text: "Deployed #{app} to #{environment}" }]) + else + MCP::Tool::Response.new([{ type: "text", text: "Deployment of #{app} was cancelled (#{action})" }]) + end + end +end + +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. +# See http_server.rb for a CORS setup for browser-based clients. +rack_app = Rack::Builder.new do + use(Rack::CommonLogger, Logger.new($stdout)) + use(Rack::ShowExceptions) + + run(transport) +end + +puts <<~MESSAGE + === MCP Modern Lifecycle Server (2026-07-28) === + + Starting server on http://localhost:9494 + + Run the SDK client against it: + ruby examples/modern_http_client.rb + + Or walk the modern lifecycle with cURL: + + 1. Probe capabilities (sessionless, works without any protocol headers): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' + + 2. Modern tools/list (the MCP-Protocol-Version header selects the era, the + Mcp-Method header mirrors the method, and params._meta carries the + SEP-2575 envelope): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \\ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + 3. Modern tools/call (name-bearing methods additionally mirror the name in + the Mcp-Name header; no Mcp-Session-Id is ever sent): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: greet" \\ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + Press Ctrl+C to stop the server +MESSAGE + +Rackup::Handler.get("puma").run(rack_app, Port: 9494, Host: "localhost") diff --git a/examples/rails/README.md b/examples/rails/README.md index 6a599c3..eaee531 100644 --- a/examples/rails/README.md +++ b/examples/rails/README.md @@ -36,7 +36,7 @@ per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` line ```console SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \ -H "Accept: application/json, text/event-stream" \ - --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ + --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ | grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') ``` diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index 79e3f38..ab7aeed 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -18,8 +18,11 @@ def create_logger logger end -# The SDK does not yet implement the optional GET SSE stream, so this example -# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream. +# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler +# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with +# raw `Net::HTTP` to show the SSE wire format. The GET stream carries server-initiated messages +# that are not tied to a client request; request-scoped notifications such as progress arrive on +# the POST's own SSE response stream (see `stream_tool_call` below). def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -64,6 +67,42 @@ def print_response(response) end end +# Calls a tool with a raw streaming POST so each SSE event on the response stream is printed as +# it arrives: the `notifications/progress` events requested via `_meta.progressToken`, +# then the final JSON-RPC response. `MCP::Client#call_tool` reads the same stream but consumes +# the progress notifications internally, so the raw request makes them visible here. +def stream_tool_call(session_id, request_id, name:, arguments:, logger:) + uri = URI(SERVER_URL) + + body = { + jsonrpc: "2.0", + id: request_id, + method: "tools/call", + params: { + name: name, + arguments: arguments, + _meta: { progressToken: "streamable-http-client" }, + }, + } + + Net::HTTP.start(uri.host, uri.port) do |http| + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Accept"] = "application/json, text/event-stream" + request["Mcp-Session-Id"] = session_id + request.body = JSON.generate(body) + + http.request(request) do |response| + parser = EventStreamParser::Parser.new + response.read_body do |chunk| + parser.feed(chunk) do |_type, data, _id| + logger.info("SSE event: #{data}") + end + end + end + end +end + def main logger = create_logger @@ -76,11 +115,17 @@ def main http_transport = MCP::Client::HTTP.new(url: SERVER_URL) client = MCP::Client.new(transport: http_transport) sse_thread = nil + raw_request_id = 0 begin puts "=== Initializing session ===" + + # The default `mode: :auto` adopts the sessionless modern lifecycle (2026-07-28) + # when the server supports it. This example demonstrates the legacy session's SSE stream, + # so pin the legacy `initialize` handshake. server_info = client.connect( client_info: { name: "streamable-http-client", version: "1.0" }, + mode: :legacy, ) puts <<~MESSAGE @@ -104,8 +149,6 @@ def main sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) } sleep(1) - # Once the optional SSE stream is active, POST requests may receive only a - # 202 ACK while the actual JSON-RPC response is delivered over SSE. loop do puts <<~MENU.chomp @@ -126,12 +169,15 @@ def main print("Enter delay in seconds (0 for immediate): ") delay = gets.chomp.to_f - puts "=== Calling tool: notification_tool ===" - response = client.call_tool( - tool: notification_tool, + puts "=== Calling tool: notification_tool (raw streaming POST) ===" + raw_request_id += 1 + stream_tool_call( + http_transport.session_id, + "raw-#{raw_request_id}", + name: notification_tool.name, arguments: { message: message, delay: delay }, + logger: logger, ) - print_response(response) else puts "notification_tool not available" end diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index a20d28e..f122296 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -13,14 +13,16 @@ sse_logger.formatter = proc do |severity, datetime, _progname, msg| "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" end -# Tool that returns a response that will be sent via SSE if a stream is active +# Tool that emits `notifications/progress` events, delivered over the SSE stream. +# The client must request them by sending a `progressToken` in the request `_meta`. class NotificationTool < MCP::Tool tool_name "notification_tool" - description "Returns a notification message that will be sent via SSE if stream is active" + description "Sends the message back as progress notifications via SSE, then returns a summary" input_schema( properties: { message: { type: "string", description: "Message to send via SSE" }, - delay: { type: "number", description: "Delay in seconds before returning (optional)" }, + steps: { type: "number", description: "Number of progress notifications to send (default: 3)" }, + delay: { type: "number", description: "Delay in seconds between notifications (optional)" }, }, required: ["message"], ) @@ -28,14 +30,19 @@ class NotificationTool < MCP::Tool class << self attr_accessor :logger - def call(message:, delay: 0) - sleep(delay) if delay > 0 + def call(message:, server_context:, steps: 3, delay: 0) + steps = steps.to_i.clamp(1, 10) - logger&.info("Returning notification message: #{message}") + steps.times do |i| + sleep(delay) if delay > 0 + + logger&.info("Reporting progress #{i + 1}/#{steps}: #{message}") + server_context.report_progress(i + 1, total: steps, message: message) + end MCP::Tool::Response.new([{ type: "text", - text: "Notification: #{message} (timestamp: #{Time.now.iso8601})", + text: "Sent #{steps} progress notifications for: #{message}", }]) end end @@ -44,6 +51,7 @@ end # Create the server server = MCP::Server.new( name: "sse_test_server", + version: "1.0.0", tools: [NotificationTool], prompts: [], resources: [], @@ -149,29 +157,34 @@ puts <<~MESSAGE Starting server on http://localhost:9393 Available Tools: - 1. NotificationTool - Returns messages that are sent via SSE when stream is active" + 1. notification_tool - Sends the message back as progress notifications via SSE 2. echo - Simple echo tool Testing SSE: 1. Initialize session: curl -i http://localhost:9393 \\ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' - 2. Connect SSE stream (use the session ID from step 1):" + 2. Connect SSE stream (use the session ID from step 1): curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393 3. In another terminal, test tools (responses will be sent via SSE if stream is active): Echo tool: curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ + -H "Accept: application/json, text/event-stream" \\ --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}' - Notification tool (with 2 second delay): + Notification tool (progress notifications require a progressToken in _meta): curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!","delay":1},"_meta":{"progressToken":"curl-progress"}}}' - Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return 202 Accepted with no body. + Note: Each POST is answered with an SSE stream of its own; request-scoped notifications + (such as the progress events above) appear on it before the final response. + The standalone GET stream carries server-initiated messages that are not tied to a request. Press Ctrl+C to stop the server MESSAGE From 920707cd7edca4aeb17dcaf9cd65ffeae031caa8 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 30 Aug 2026 02:09:00 +0900 Subject: [PATCH 225/247] Group the conformance Rake tasks under a `conformance` namespace ## Motivation and Context The three conformance tasks were named `conformance`, `conformance_list`, and `conformance_server`, spelling out with underscores the grouping that Rake expresses with namespaces. They are now `conformance:test`, `conformance:list`, and `conformance:server`. The bare `conformance` task is kept as an alias for `conformance:test`. ## How Has This Been Tested? By running `rake -T` to confirm the three tasks are grouped under `conformance:` with the alias listed beside them, `rake --dry-run default` to confirm that the default task resolves `conformance:test`, and `rake conformance:list` to exercise a renamed task end to end. ## Breaking Changes None. These are development-only Rake tasks. --- .github/workflows/conformance.yml | 2 +- AGENTS.md | 4 +- Rakefile | 68 +++++++++++++++++-------------- conformance/README.md | 16 ++++---- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dccb8af..ff178c2 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -26,4 +26,4 @@ jobs: - uses: actions/setup-node@v7 with: node-version: '24' # Specify the latest Node.js version. - - run: bundle exec rake conformance + - run: bundle exec rake conformance:test diff --git a/AGENTS.md b/AGENTS.md index 5d34462..6635c3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,8 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - `bundle install` - Install dependencies - `rake test` - Run all tests - `rake rubocop` - Run linter -- `rake` - Run tests and linting (default task) -- `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md) +- `rake` - Run linting, tests, and the conformance suite (default task) +- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md) - `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem diff --git a/Rakefile b/Rakefile index c795e56..409eef9 100644 --- a/Rakefile +++ b/Rakefile @@ -14,42 +14,50 @@ require "rubocop/rake_task" RuboCop::RakeTask.new -task default: [:rubocop, :test, :conformance] +task default: [:rubocop, :test, "conformance:test"] -desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" -task :conformance do |t| - next unless npx_available?(t.name) +namespace :conformance do + desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" + task :test do |t| + next unless npx_available?(t.name) - require_relative "conformance/server_runner" - require_relative "conformance/client_runner" + require_relative "conformance/server_runner" + require_relative "conformance/client_runner" - options = {} - options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] - options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"] - options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] - options[:verbose] = true if ENV["VERBOSE"] + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"] + options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] + options[:verbose] = true if ENV["VERBOSE"] - Conformance::ServerRunner.new(**options).run - Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run + Conformance::ServerRunner.new(**options).run + Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run + end + + desc "List available conformance scenarios" + task :list do |t| + next unless npx_available?(t.name) + + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") + end + + desc "Start the conformance server (PORT)" + task :server do + require_relative "conformance/server" + + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + + Conformance::Server.new(**options).start + end end -desc "List available conformance scenarios" -task :conformance_list do |t| - next unless npx_available?(t.name) - - system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") - system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") -end - -desc "Start the conformance server (PORT)" -task :conformance_server do - require_relative "conformance/server" - - options = {} - options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] - - Conformance::Server.new(**options).start -end +# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance` +# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without +# running anything. This keeps the name pointing at the suite instead of at that silent no-op. +desc "Alias for conformance:test" +task conformance: "conformance:test" namespace :docs do desc "Serve the documentation site locally at http://localhost:4000 (PORT)" diff --git a/conformance/README.md b/conformance/README.md index ea2c917..1e2e660 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont ### Run all scenarios ```bash -bundle exec rake conformance +bundle exec rake conformance:test ``` Runs both server and client conformance tests in sequence. Server conformance starts the @@ -31,20 +31,20 @@ are allowed to fail without affecting the exit code. ```bash # Run a single server scenario -bundle exec rake conformance SCENARIO=ping +bundle exec rake conformance:test SCENARIO=ping # Use a different port with verbose output -bundle exec rake conformance PORT=3000 VERBOSE=1 +bundle exec rake conformance:test PORT=3000 VERBOSE=1 # Start the server on a specific port -bundle exec rake conformance_server PORT=3000 +bundle exec rake conformance:server PORT=3000 ``` ### Start the server and test separately ```bash # Terminal 1: start the server -bundle exec rake conformance_server +bundle exec rake conformance:server # Terminal 2: run all scenarios npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp @@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done. ### List available scenarios ```bash -bundle exec rake conformance_list +bundle exec rake conformance:list ``` Prints all scenario names that can be passed to `SCENARIO`. @@ -76,7 +76,7 @@ repository with the conformance server running: ```bash # Terminal 1 (this repository): start the conformance server -bundle exec rake conformance_server +bundle exec rake conformance:server # Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code /mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp @@ -102,4 +102,4 @@ conformance/ Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to fail without affecting the exit code and are tracked to catch regressions. -These are shown in the output of `bundle exec rake conformance`. +These are shown in the output of `bundle exec rake conformance:test`. From 09d4bf41aa0f2143fdd8a0ef61a7b64a58f9c37b Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 30 Aug 2026 06:41:27 +0900 Subject: [PATCH 226/247] Let the embedding application refuse an authorization request ## Motivation and Context An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers` in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity. No origin rule can settle this, because an authorization server is legitimately a different origin from the resource it protects. Validating that a token was issued for the intended audience is a responsibility the specification places on MCP servers rather than on clients, and there is no portable way for a client to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor the `resource` parameter or to report an audience in the token response. Two things follow from that, and they are separate. The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse. It is one object rather than keyword arguments so that later revisions of the specification can add to it without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`: a refusal must not leave this client registered at an authorization server the host just rejected. The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers the authorization server on every call, so the server named for a session can differ from the one the stored tokens came from. SEP-2352 already binds client credentials to their issuer, but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL, which is portable across authorization servers. The issuer that minted the tokens is now recorded with them, and `refresh!` refuses to present them to a different one. The transport answers that refusal by running a full authorization, which brings the new authorization server to the validator above. Tokens stored before this carry no issuer and keep refreshing. The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way, because the specification requires a client to treat the scopes in the challenge as authoritative for the operation, and narrowing them silently would break that. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises before any registration request is sent and before the redirect handler runs, and that the `client_credentials` and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb` drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens, and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer it authorizes as before. --- docs/_client/authorization.md | 77 +++++++- lib/mcp/client/oauth.rb | 1 + lib/mcp/client/oauth/authorization_request.rb | 27 +++ .../oauth/client_credentials_provider.rb | 4 +- .../client/oauth/cross_app_access_provider.rb | 3 +- lib/mcp/client/oauth/flow.rb | 111 +++++++++-- lib/mcp/client/oauth/provider.rb | 4 +- .../client/oauth/storage_backed_provider.rb | 7 + test/mcp/client/oauth/flow_test.rb | 173 ++++++++++++++++++ test/mcp/client/oauth/http_oauth_test.rb | 88 +++++++++ 10 files changed, 478 insertions(+), 17 deletions(-) create mode 100644 lib/mcp/client/oauth/authorization_request.rb diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index 1c73eeb..a673a19 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -93,10 +93,14 @@ Required keyword arguments to `Provider.new`: Optional keyword arguments: - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built. + Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request). - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically - (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. + (portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what + lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members + instead drops these bindings with no error. - `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). When the authorization server advertises `client_id_metadata_document_supported: true`, @@ -182,7 +186,7 @@ Keyword arguments: - `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` or `"RS256"`; `client_secret` must not be set, because the private key is the credential. -- `scope`, `storage`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. ### Cross-App Access (JWT Bearer) Grant @@ -219,7 +223,7 @@ Keyword arguments: - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). Passing both through to `IDJAGTokenExchange.request` covers the common case. -- `scope`, `storage`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. ### Communication Security @@ -258,3 +262,70 @@ The SDK also bounds what those endpoints may return. A discovery, dynamic client measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through `http_client_factory:` is bounded as well, so there is no way to opt out of it. + +### Reviewing the authorization request + +The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want +your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes +it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects, +so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on +MCP servers rather than on clients. + +`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so: + +```ruby +ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"] + +provider = MCP::Client::OAuth::Provider.new( + # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. + authorization_request_validator: ->(request) { + ALLOWED_ISSUERS.include?(request.authorization_server) + }, +) +``` + +The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither +the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification +can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide +(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes. + +`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized +(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one, +the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce +a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured. + +Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path, +and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising. + +The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has +any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it: + +```ruby +ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] } + +provider = MCP::Client::OAuth::Provider.new( + # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. + authorization_request_validator: ->(request) { + ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) } + }, +) +``` + +A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them. + +It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential, +or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened, +and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server +the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected. +Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it, +while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure. + +The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification +requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for, +which is what every MCP SDK does today. + +It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for. +An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to +present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is. +The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse. +Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization. diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index 4fc615b..ecacc88 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "oauth/authorization_request" require_relative "oauth/bounded_body" require_relative "oauth/discovery" require_relative "oauth/flow" diff --git a/lib/mcp/client/oauth/authorization_request.rb b/lib/mcp/client/oauth/authorization_request.rb new file mode 100644 index 0000000..57462f5 --- /dev/null +++ b/lib/mcp/client/oauth/authorization_request.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to, + # and with which scopes. + # + # Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it + # without changing the shape every implementer wrote. + # + # Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not, + # so the representation can change later. + AuthorizationRequest = Struct.new( + # `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata. + :authorization_server, + # The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`. + :scopes, + # The MCP server URL this client was configured with. + :server_url, + # The RFC 8707 `resource` the token is being requested for. + :resource, + keyword_init: true, + ) + end + end +end diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index 688ff17..21276bb 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -60,7 +60,8 @@ module MCP private_key: nil, signing_algorithm: nil, scope: nil, - storage: nil + storage: nil, + authorization_request_validator: nil ) if blank?(client_id) raise InvalidCredentialsError, "client_id is required for the client_credentials grant." @@ -103,6 +104,7 @@ module MCP @signing_algorithm = signing_algorithm @scope = scope @storage = storage || InMemoryStorage.new + @authorization_request_validator = authorization_request_validator @storage.save_client_information(client_information) end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb index af5b599..67342c0 100644 --- a/lib/mcp/client/oauth/cross_app_access_provider.rb +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -35,7 +35,7 @@ module MCP attr_reader :scope, :storage - def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil) + def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil) if blank?(client_id) raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." end @@ -51,6 +51,7 @@ module MCP @assertion_provider = assertion_provider @scope = scope @storage = storage || InMemoryStorage.new + @authorization_request_validator = authorization_request_validator @storage.save_client_information( "client_id" => client_id, "client_secret" => client_secret, diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 68ccd9c..9a825c0 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -23,6 +23,11 @@ module MCP # should leave the refresh token intact. class InvalidGrantError < AuthorizationError; end + # Raised when `authorization_request_validator` returns a falsy value. Separate from its parent + # so that a caller can tell a refusal by its own policy from a network, discovery, + # or authorization server metadata failure by rescuing a class rather than by matching the message text. + class AuthorizationRefusedError < AuthorizationError; end + def initialize(provider:, http_client_factory: nil) @provider = provider @http_client_factory = http_client_factory || -> { default_http_client } @@ -63,17 +68,22 @@ module MCP case provider_authorization_flow when :client_credentials - return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) + return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) when :jwt_bearer - return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) + return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) end ensure_pkce_supported!(as_metadata) - client_info = ensure_client_registered(as_metadata: as_metadata) - effective_scope = resolve_scope(scope: scope, prm: prm || {}) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) + + # Asked before registering, not after: a refusal must not leave this client registered at an authorization server + # the embedding application has just rejected. + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) + + client_info = ensure_client_registered(as_metadata: as_metadata) + pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) @@ -109,7 +119,7 @@ module MCP resource: resource, ) - @provider.save_tokens(tokens) + save_tokens_issued_by(tokens, as_metadata: as_metadata) :authorized end @@ -119,17 +129,19 @@ module MCP # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - def run_client_credentials!(as_metadata:, prm:, resource:, scope:) + def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:) client_info = client_credentials_client_info ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) form = { "grant_type" => "client_credentials" } effective_scope = resolve_scope(scope: scope, prm: prm) + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) form["scope"] = effective_scope if effective_scope form["resource"] = resource if resource tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) - @provider.save_tokens(tokens) + save_tokens_issued_by(tokens, as_metadata: as_metadata) + :authorized end @@ -173,12 +185,18 @@ module MCP # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request. # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated. # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 - def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:) + def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:) client_info = @provider.client_information unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." end + # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant: + # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once + # the host has refused it. + effective_scope = resolve_scope(scope: scope, prm: prm) + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) + assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource) if assertion.nil? || assertion.to_s.empty? raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion." @@ -188,12 +206,11 @@ module MCP "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion, } - effective_scope = resolve_scope(scope: scope, prm: prm) form["scope"] = effective_scope if effective_scope form["resource"] = resource if resource tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) - @provider.save_tokens(tokens) + save_tokens_issued_by(tokens, as_metadata: as_metadata) :authorized end @@ -242,6 +259,8 @@ module MCP server_url: server_url, ) + ensure_token_issuer!(as_metadata: as_metadata) + client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. @@ -262,7 +281,7 @@ module MCP resource: resource, ) - @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token)) + save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata) :refreshed end @@ -499,6 +518,43 @@ module MCP # the authorization server on two loopback ports), and deployments that never leave a corporate network. # Only a server reachable on the public internet is barred from steering the client inward. # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + # Hands the embedding application the authorization server and the scopes that are about to be requested, + # and abandons the flow when it refuses them. + # + # Both values are chosen by the MCP server: it names its own authorization server in Protected + # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge. + # Neither the specification nor any MCP SDK binds that choice to the server's own identity, + # and validating that a token was issued for the intended audience is a responsibility the specification + # places on MCP servers rather than on clients. + # A host that knows which providers its user deals with can apply that knowledge here. + # + # The scopes are passed on unchanged whatever the host decides, because the specification requires + # a client to treat the challenged scopes as authoritative for the operation; the choice offered is + # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before. + # + # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered + # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead: + # an authorization server that has changed since the tokens were issued sends the flow back through here, + # where the host sees the new one and decides again. + def authorize_request!(as_metadata:, scope:, server_url:, resource:) + return unless @provider.respond_to?(:authorization_request_validator) + return unless (validator = @provider.authorization_request_validator) + + request = AuthorizationRequest.new( + authorization_server: as_metadata["issuer"], + scopes: scope.to_s.split, + server_url: server_url, + resource: resource, + ).freeze + + return if validator.call(request) + + raise AuthorizationRefusedError, <<~MESSAGE + The authorization request was refused by `authorization_request_validator` \ + (authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}). + MESSAGE + end + def ensure_routable_destination!(url, label:, server_url:) return unless private_network_url?(url) return if private_network_url?(server_url) @@ -669,6 +725,39 @@ module MCP # the error and falls back to the full flow, which discards the stale registration and re-registers. # Credentials without an `"issuer"` binding predate this check and are allowed through; # CIMD `client_id`s are portable. + # Records which authorization server minted these tokens, alongside the tokens themselves. + # SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that + # SEP leaves unbound. + # Stored in the token hash so it travels through any `storage` a caller supplied, without widening + # the storage interface that custom implementations have to satisfy. + def save_tokens_issued_by(tokens, as_metadata:) + issuer = as_metadata["issuer"] + tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer + + @provider.save_tokens(tokens) + end + + # Refuses to present a refresh token to an authorization server other than the one that issued it. + # `refresh!` rediscovers the authorization server from Protected Resource Metadata every time, + # so the server named for a session can differ from the one the stored tokens came from. + # Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is, + # including a Client ID Metadata Document URL, which is portable across authorization servers precisely + # so that re-registration is unnecessary. + # + # Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats + # client information without one; the binding takes effect from their next authorization. + # The caller answers a refusal by running the full authorization flow, which is where the embedding application + # is asked about the new authorization server. + def ensure_token_issuer!(as_metadata:) + stored_issuer = read_token("issuer") + return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"] + + raise AuthorizationError, <<~MESSAGE + Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \ + current #{as_metadata["issuer"].inspect}); re-authorization is required. + MESSAGE + end + def ensure_refreshable_client_information!(client_info, as_metadata:) stored_issuer = client_info_required_value(client_info, "issuer") return if stored_issuer.nil? diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 741c6c5..7d1a0f6 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -86,7 +86,8 @@ module MCP callback_handler:, scope: nil, storage: nil, - client_id_metadata_document_url: nil + client_id_metadata_document_url: nil, + authorization_request_validator: nil ) unless Discovery.secure_url?(redirect_uri) raise InsecureRedirectURIError, @@ -115,6 +116,7 @@ module MCP @scope = scope @storage = storage || InMemoryStorage.new @client_id_metadata_document_url = client_id_metadata_document_url + @authorization_request_validator = authorization_request_validator end # Identifies the OAuth flow this provider drives. diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb index 499db6b..44d09a1 100644 --- a/lib/mcp/client/oauth/storage_backed_provider.rb +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -14,6 +14,13 @@ module MCP # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` # (see `InMemoryStorage`). module StorageBackedProvider + # Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`. + # An MCP server names its own authorization server and the scopes to ask for, so this is where + # an embedding application sees both before the request is made and can decline to proceed. + # `nil` (the default) authorizes whatever the server asked for, which is the behavior every + # MCP SDK has today. + attr_reader :authorization_request_validator + def access_token tokens&.dig("access_token") || tokens&.dig(:access_token) end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 511e23f..dc78e03 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -363,6 +363,107 @@ module MCP Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) end + # Runs the authorization-code flow with an `authorization_request_validator` that records what it + # was handed and answers `approve`. + private def run_flow_with_validator(approve:, recorder: []) + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + recorder << :redirected + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + authorization_request_validator: ->(request) { + recorder << request + approve + }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + def test_run_hands_the_authorization_server_and_scopes_to_the_validator + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: [@auth_base], + scopes_supported: ["Mail.Read", "Files.ReadWrite.All"], + ), + ) + + recorder = [] + run_flow_with_validator(approve: true, recorder: recorder) + + request = recorder.first + + assert_equal(@auth_base, request.authorization_server) + assert_equal(["Mail.Read", "Files.ReadWrite.All"], request.scopes) + assert_equal(@server_url, request.server_url) + assert_equal("https://srv.example.com/mcp", request.resource) + end + + def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines + recorder = [] + + error = assert_raises(Flow::AuthorizationRefusedError) do + run_flow_with_validator(approve: false, recorder: recorder) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + + # Refused before registration, so nothing of this client is left at the authorization server + # the host just rejected, and no browser was opened. + assert_not_requested(:post, "#{@auth_base}/register") + refute_includes(recorder, :redirected) + end + + def test_run_client_credentials_consults_the_validator + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + authorization_request_validator: ->(_request) { false }, + ) + + error = assert_raises(Flow::AuthorizationRefusedError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_jwt_bearer_consults_the_validator + minted = false + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + minted = true + "id-jag-assertion" + }, + authorization_request_validator: ->(_request) { false }, + ) + + error = assert_raises(Flow::AuthorizationRefusedError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + + # Refused before the identity provider was asked for an assertion carrying this authorization server as its audience. + refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server") + end + def test_run_registers_native_application_type_for_loopback_redirect_uri run_authorization_flow @@ -2264,6 +2365,64 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end + def test_run_records_the_issuer_that_minted_the_tokens + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(@auth_base, provider.tokens["issuer"]) + end + + def test_refresh_refuses_an_authorization_server_that_did_not_issue_the_tokens + # The stored tokens came from a different authorization server, so this refresh token must not reach the one named now. + stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("must not reach the token endpoint.")) + + provider = refresh_only_provider + provider.save_tokens( + "access_token" => "stale-at", + "refresh_token" => "saved-rt", + "issuer" => "https://issued-by.example.com", + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issued by a different authorization server/, error.message) + + # Left in place: it is still good at the server that issued it, and the caller answers this by running the full authorization flow. + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_proceeds_for_tokens_stored_before_the_issuer_was_recorded + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), + ) + + provider = refresh_only_provider + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + + # Recorded on the way out, so the binding takes effect from here on. + assert_equal(@auth_base, provider.tokens["issuer"]) + end + def test_refresh_succeeds_when_stored_issuer_matches stub_request(:post, "#{@auth_base}/token").with( body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"), @@ -2913,6 +3072,20 @@ module MCP URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp" end end + + private + + def refresh_only_provider + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + + provider + end end end end diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 73fcf04..9970971 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -1433,6 +1433,94 @@ module MCP assert_requested(:post, "#{@auth_base}/token", times: 1) end + def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens + # The refusal in `refresh!` is only half the answer; this is the other half. + # A renamed authorization server makes the refresh fail, the transport falls through to a full authorization, + # and that is where the embedding application is asked about the new one. + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"] != "Bearer fresh-at" + }.to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer fresh-at" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: @mcp_url, authorization_servers: [@auth_base]), + ) + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "reauthorized-client"), + ) + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), + ) + + seen = [] + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + authorization_request_validator: ->(request) { + seen << request.authorization_server + true + }, + ) + + # Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is + # what stops it rather than a missing client identity. + provider.save_client_information("client_id" => "test-client") + provider.save_tokens( + "access_token" => "stale-at", + "refresh_token" => "saved-rt", + "issuer" => "https://issued-by.example.com", + ) + + HTTP.new(url: @mcp_url, oauth: provider).send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # The refresh token was never presented to the authorization server now being named. + assert_not_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token" + end + assert_equal([@auth_base], seen) + assert_equal(@auth_base, provider.tokens["issuer"]) + end + private # Stubs PRM, AS metadata, /register, and /token so a full authorization From 6f0b85486bd1fd43ee7975939c31605f60fdbfdd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 1 Sep 2026 01:14:59 +0900 Subject: [PATCH 227/247] [Doc] Mark the deprecated features on the server overview The overview's Key Features and Supported Methods listed `roots`, `sampling`, and `logging/setLevel` with no deprecation marking, while their own pages carry the SEP-2577 warnings, presenting a current-looking feature surface to a reader skimming the overview. The five entries now carry the pages' "deprecated as of 2026-07-28" wording; `elicitation/create` stays unmarked - it is not deprecated, and the modern lifecycle keeps carrying it as requests embedded per the multi round-trip requests pattern. --- docs/_server/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/_server/index.md b/docs/_server/index.md index 2872959..5d938f1 100644 --- a/docs/_server/index.md +++ b/docs/_server/index.md @@ -22,8 +22,8 @@ It implements the Model Context Protocol specification, handling model context r - Supports resource registration and retrieval - Supports stdio and Streamable HTTP (including SSE) transports - Supports notifications for list changes (tools, prompts, resources) -- Supports roots (server-to-client filesystem boundary queries) -- Supports sampling (server-to-client LLM completion requests) +- Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28) +- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28) - Supports cursor-based pagination for list operations - Supports cancellation of in-flight requests on both server and client (notifications/cancelled) @@ -37,7 +37,7 @@ It implements the Model Context Protocol specification, handling model context r - Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/) - `ping` - Simple health check -- `logging/setLevel` - Configures the minimum log level for the server +- `logging/setLevel` - Configures the minimum log level for the server (deprecated as of 2026-07-28) - `tools/list` - Lists all registered tools and their schemas - `tools/call` - Invokes a specific tool with provided arguments - `prompts/list` - Lists all registered prompts and their schemas @@ -48,6 +48,6 @@ It implements the Model Context Protocol specification, handling model context r - `resources/subscribe` - Subscribes to updates for a specific resource - `resources/unsubscribe` - Unsubscribes from updates for a specific resource - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs -- `roots/list` - Requests filesystem roots from the client (server-to-client) -- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) +- `roots/list` - Requests filesystem roots from the client (server-to-client; deprecated as of 2026-07-28) +- `sampling/createMessage` - Requests LLM completion from the client (server-to-client; deprecated as of 2026-07-28) - `elicitation/create` - Requests user input from the client (server-to-client) From 088f4b99e4fdc9db1bb562be2591fc8425e14a4c Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 3 Sep 2026 16:55:06 +0900 Subject: [PATCH 228/247] Answer server-to-client pings in the client ## Motivation and Context The ping utility says the receiver "MUST respond promptly with an empty response", and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor that on the client side (the TypeScript protocol layer installs an automatic pong, and the Python session answers `PingRequest` with an empty result), but this client answered a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio, leaving long-lived legacy sessions vulnerable to a liveness-checking server. Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`. The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens the GET listening stream on connect, and a built-in pong must not change listener behavior - a server can only ping on a stream that is already open. A handler registered via `on_server_request("ping")` still wins. Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response. Frames are told apart by shape rather than id: one carrying `method` is a request or notification, never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered - and a request frame can no longer be returned to the caller as if it were the response. Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading, which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay unsupported as documented. The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading, and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright - is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found, exactly as before this change. The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only. ## How Has This Been Tested? New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while a response is awaited is answered on stdin and the original response still returns. Both fail without the change (Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None. A server ping that previously received Method not found (or nothing) now receives the empty result the specification requires. --- docs/_client/ping.md | 10 ++ docs/_client/transports.md | 6 + lib/mcp/client/http.rb | 13 ++ lib/mcp/client/stdio.rb | 28 ++++ test/mcp/client/http_test.rb | 125 ++++++++++++++++++ test/mcp/client/stdio_test.rb | 239 ++++++++++++++++++++++++++++++++++ 6 files changed, 421 insertions(+) diff --git a/docs/_client/ping.md b/docs/_client/ping.md index e01e21b..f6889e9 100644 --- a/docs/_client/ping.md +++ b/docs/_client/ping.md @@ -29,6 +29,16 @@ is not a Hash (matching the spec requirement that `result` be an object). Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) propagate as exceptions raised by the transport layer. +## Answering Server Pings + +On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with +the empty result - no handler is needed. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces +the automatic answer. +Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when +the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is +dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:` +on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits. + ## Server Side How servers answer `ping` requests and ping the client themselves is documented on diff --git a/docs/_client/transports.md b/docs/_client/transports.md index 1e1aaac..a8a4764 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -233,6 +233,12 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f end ``` +{: .note } +> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside +> the SSE streaming callback of another response, re-entering the connection on the same thread. +> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter +> with persistent connections must tolerate that re-entry. + ## Custom Transports If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index e189c74..1c3fd86 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1118,6 +1118,19 @@ module MCP }, } end + elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric)) + # The ping receiver "MUST respond promptly with an empty response" (MCP ping utility), + # so an unhandled ping is answered with the empty result rather than Method not found. + # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at + # schema validation, so a ping carrying one falls through to Method not found instead. + # The default lives here instead of in `@server_request_handlers`, whose `any?` opens + # the GET listening stream on connect: a built-in pong must not change listener behavior. + # A handler registered via `on_server_request("ping")` still wins through the branch above. + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + result: {}, + } else { jsonrpc: JsonRpcHandler::Version::V2_0, diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index fee1a3d..0fbb96e 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -436,6 +436,18 @@ module MCP parsed = JSON.parse(line.strip) + # A frame carrying `method` is a request or notification, never the awaited response, + # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id + # may legitimately collide with the awaited one. Pings are answered; + # other server-to-client requests over stdio stay unsupported as documented, + # and notifications carry no id. + if parsed.is_a?(Hash) && parsed.key?("method") + # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at + # schema validation, so a ping carrying one is skipped rather than echoed back. + answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) + next + end + # A JSON-RPC message is an object; skip a non-object frame (array or scalar) # the same way as a frame without an id. next unless parsed.is_a?(Hash) && parsed.key?("id") @@ -451,6 +463,22 @@ module MCP ) end + # The ping receiver "MUST respond promptly with an empty response". Answered inline from + # the read loop, so a keepalive cannot stall unanswered while a response is awaited; + # between requests nothing reads stdout, and a queued ping is answered on the next read. + def answer_ping(parsed) + @write_mutex.synchronize do + write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} }) + end + rescue RequestHandlerError + # Best effort: a pong cannot be delivered over a broken stdin, and the failure must not + # surface as an error of the unrelated request whose response the loop is reading. + end + + def json_rpc_id?(id) + id.is_a?(String) || id.is_a?(Numeric) + end + def ensure_running! return if @wait_thread.alive? diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 87cdb55..077db31 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1303,6 +1303,131 @@ module MCP assert_equal(100, received_params["maxTokens"]) end + def test_send_request_answers_a_server_ping_with_an_empty_result + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 7, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + pong = { jsonrpc: "2.0", id: 7, result: {} } + pong_stub = stub_request(:post, url).with( + body: pong.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(pong_stub) + end + + def test_a_registered_ping_handler_overrides_the_default_pong + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 8, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { jsonrpc: "2.0", id: 8, result: { custom: "pong" } } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return(status: 202, body: "") + + client.on_server_request("ping") { { custom: "pong" } } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + end + + def test_a_ping_with_a_malformed_id_is_answered_with_method_not_found + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames at schema + # validation, so the default pong does not fire and the frame falls through. + server_ping = { jsonrpc: "2.0", id: { x: 1 }, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error = { + jsonrpc: "2.0", + id: { x: 1 }, + error: { + code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found: ping", + }, + } + error_stub = stub_request(:post, url).with( + body: expected_error.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + def test_send_request_answers_unregistered_server_request_with_method_not_found request = { jsonrpc: "2.0", diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 3b16040..08e8d17 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -85,6 +85,245 @@ module MCP stderr_read.close end + def test_send_request_answers_a_server_ping_inline + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Ping the client while it awaits the response; the receiver "MUST respond promptly". + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-1", pong["id"]) + assert_equal({}, pong["result"]) + refute(pong.key?("error")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # JSON-RPC id spaces are per sender, so a server ping may reuse the id the client + # is awaiting; it must still be answered rather than treated as the response. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + pong = JSON.parse(pong_line) + assert_equal("test-id", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Break the write path: the pong cannot be delivered, and that must not fail the request. + stdin_read.close + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close unless stdin_read.closed? + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_ping_with_a_malformed_id_is_not_answered + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" })) + stdout_write.flush + + # The single pong that arrives must answer the well-formed ping, not the malformed one. + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-2", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_send_request_skips_notifications stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe From 09c310341b9421189e0ba0b1c58f805a2ad7c43f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 4 Sep 2026 00:16:39 +0900 Subject: [PATCH 229/247] Fix the flaky thread count assertion in the listen keepalive test ## Motivation and Context "listen keepalive is not started when the interval is nil" fails intermittently on CI. The most recent run failed on Ruby 3.2 alone while 3.1 and head passed, and a re-run of the same commit went green. The assertion samples a process-wide count: ```ruby before = Thread.list.size open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) assert_equal before, Thread.list.size, "a nil interval must not spawn a keepalive thread" ``` `Thread.list` covers the whole process, so the count moves for reasons this test has no interest in. The observed failure reported 54 against 51: three threads had *gone away* between the samples, not appeared. This file starts threads in sixteen places, and the SSE stream and reaper threads they leave behind finish whenever they finish, so whether the count holds still depends on the test order and on the scheduler rather than on the behavior under test. The test itself is asking the right question. `setup` builds its transport with `listen_keepalive_interval: nil` precisely so opening a listen stream spawns no timer, and the point is to hold that guarantee. Only the measurement is wrong, so this compares the set of threads instead: a thread that disappears no longer registers, and a thread that appears still does. Minitest runs these tests serially and `open_listen_stream` drives the SSE body inline rather than on a thread, so a keepalive timer is the only thread that can appear across the two samples. No library code changes. ## How Has This Been Tested? The rewritten assertion was checked for sensitivity before being kept: a temporary probe pointed `open_listen_stream` at a transport built with `listen_keepalive_interval: 15` and asserted the set difference was *not* empty. It passed, confirming the assertion still catches a spawned keepalive thread rather than merely never failing. The probe was removed afterwards. The failure did not reproduce locally beforehand, across six seeds of the file on its own and six full-suite runs, which matches a timing-sensitive flake that needs a slower machine. ## Breaking Changes None. --- .../server/transports/streamable_http_transport_test.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 36fe9ac..bf032b2 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6307,10 +6307,13 @@ module MCP end test "listen keepalive is not started when the interval is nil" do - before = Thread.list.size + # The set of threads, not their count: `Thread.list` is process-wide, so a thread another test left running + # that finishes between the two samples moves the count in the direction this assertion does not care about. + # Only a thread that appears is evidence of a keepalive timer. + before = Thread.list open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - assert_equal before, Thread.list.size, "a nil interval must not spawn a keepalive thread" + assert_empty(Thread.list - before, "a nil interval must not spawn a keepalive thread") end test "listen_keepalive_interval rejects a non-positive value" do From 33525d6494ff3b9ffa81a708151a5b0c2e5fb5db Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 5 Sep 2026 02:28:50 +0900 Subject: [PATCH 230/247] Release 1.5.0 This release makes the client answer a server's `ping` with the empty result the specification requires: earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio, so a server that checks liveness dropped their long-lived sessions. On the OAuth side, the embedding application can refuse an authorization request through `authorization_request_validator`, and stored tokens are refreshed only against the authorization server that issued them. The validator is what makes this a minor release. The refresh check is the one behavior change: when the authorization server named for a session differs from the one that issued the stored tokens, the client reauthorizes instead of refreshing, and tokens stored by earlier releases, which carry no issuer, keep refreshing. --- CHANGELOG.md | 18 ++++++++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a301c..051f239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] - 2026-09-05 + +This release makes the client answer a server's `ping` with the empty result the specification requires. +Earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio, +so a server that checks liveness dropped their long-lived sessions. On the OAuth side, +the embedding application can refuse an authorization request through `authorization_request_validator`, +and stored tokens are refreshed only against the authorization server that issued them: +when the server named for a session differs, the client reauthorizes instead of refreshing, +and the validator sees the newly named server. Tokens stored by earlier releases carry no issuer and keep refreshing. + +### Added + +- Add `authorization_request_validator` to let the embedding application refuse an authorization request (#539) + +### Fixed + +- Answer server-to-client pings in the client (#541) + ## [1.4.0] - 2026-08-28 This release repairs the `subscriptions/listen` stream. A host that buffers its responses, diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 8699187..1bbb8dd 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.4.0" + VERSION = "1.5.0" end From 0da02918877bbef8a243b134ce9caa8b87a4a226 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 6 Sep 2026 03:19:52 +0900 Subject: [PATCH 231/247] [Doc] Explain when the HTTP client needs event_stream_parser ## Motivation and Context The Installation page names faraday as the example of an optional dependency but leaves out event_stream_parser, which the HTTP client transport needs as soon as a server answers with SSE. The Transports page documents both gems, so a reader who starts from Installation learns about the second one only when a streaming response fails to parse. The Transports page also marks event_stream_parser as "optional, required only for SSE responses", which reads as a corner case. Whether a response is JSON or SSE is the server's choice, made for each response, a client must accept both, and this SDK's own server picks SSE by default and always on the modern lifecycle, so a client written for arbitrary servers needs both gems. Both pages now say so, and the Transports page spells out the one setup that can leave the gem out: a server known to answer with JSON alone, such as this SDK's server in JSON response mode serving handshake-lifecycle clients, and no listening stream. The Installation page shows the faraday-only Gemfile first and then the one that adds event_stream_parser, so both cases are visible at a glance. ## How Has This Been Tested? Documentation only; the Gemfile snippets on both pages match. ## Breaking Changes None. --- docs/_client/transports.md | 9 ++++++++- docs/installation.md | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/_client/transports.md b/docs/_client/transports.md index a8a4764..a30259c 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -64,7 +64,14 @@ The stdio transport automatically handles: Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. -You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: +You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser` +to read SSE (`text/event-stream`) responses. +Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both, +so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always +does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well. +`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to +answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode) +serving handshake-lifecycle clients, and only while no listening stream is opened: ```ruby gem "mcp" diff --git a/docs/installation.md b/docs/installation.md index f93c6e5..91aaee1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,6 +38,16 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: ```ruby -gem "mcp" gem "faraday", ">= 2.0" ``` + +Reading SSE (`text/event-stream`) responses needs `event_stream_parser` as well. +Whether a response is JSON or SSE is the server's choice, made for each response, and this SDK's own server picks SSE by default, +so a client written for arbitrary servers needs both gems: + +```ruby +gem "faraday", ">= 2.0" +gem "event_stream_parser", ">= 1.0" +``` + +The [Transports](/client/transports/#http-transport-layer) page describes the one setup where `faraday` alone is enough. From 09c89aef14fbbb5b6f9a05a65c355daf17a6c719 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 8 Sep 2026 17:04:43 +0900 Subject: [PATCH 232/247] Parse HTTP client response bodies without Faraday's JSON middleware ## Motivation and Context json 3.0, released on 2026-09-07, accepts the options of `JSON.parse` as keywords only, and Faraday's json response middleware, through 2.14.3, hands them over as a positional Hash, which Ruby 3 no longer converts. With both installed, every JSON body the client receives fails inside that middleware, and `MCP::Client::HTTP` reports each request as an internal error. Faraday's main branch passes the options as keywords, but no release carries that yet. The client already parses streamed bodies and buffered SSE bodies itself; only a JSON body delivered whole by an adapter without streaming support still went through the middleware. The client now parses that body too and no longer registers the middleware, so every body takes `parse_json_buffer` and a malformed one answers `:parse_error` whichever way it arrived. A JSON middleware added through the Faraday customizer block still works, since a body it has parsed already is used as is. ## How Has This Been Tested? A new test drives a buffered JSON response through a stand-in `JSON.parse` with the json 3.0 signature, options as keywords only, so on Ruby 3 it failed with the middleware registered whatever json version is installed and passes now. The existing buffered-body test keeps covering the plain path. The full suite passes on Ruby 3.0.7 with json 3.0.1 and faraday 2.14.3, where five client tests failed before. ## Breaking Changes None. A malformed JSON body delivered by an adapter without streaming support now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did, instead of `:internal_error` wrapping a `Faraday::ParsingError`. --- lib/mcp/client/http.rb | 7 ++++--- test/mcp/client/http_test.rb | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 1c3fd86..6969b07 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -723,7 +723,8 @@ module MCP require_faraday! @client ||= Faraday.new(url) do |faraday| faraday.request(:json) - faraday.response(:json) + # The client parses response bodies itself (`resolve_response_body`), so streamed and buffered responses + # take the same path; Faraday's json response middleware is deliberately left out. faraday.response(:raise_error) faraday.headers["Accept"] = ACCEPT_HEADER @@ -1074,8 +1075,8 @@ module MCP elsif content_type&.include?("application/json") return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty? - # Adapters without `on_data` support deliver the body via `response.body`, - # already parsed by the json response middleware. + # Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware + # added by the Faraday customizer may have parsed it already. response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body else raise RequestHandlerError.new( diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 077db31..0b9960a 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -940,6 +940,26 @@ module MCP assert_equal({ "result" => { "tools" => [] } }, response) end + def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_only + # json 3.0 accepts parser options as keywords only, and Faraday's json response middleware + # passes them as a positional Hash, which Ruby 3 no longer converts. The stand-in parser has + # the json 3.0 signature, so the body must reach `JSON.parse` through the client's own call. + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + parse = JSON.method(:parse) + keyword_options_only_parse = ->(source, **options) { parse.call(source, **options) } + + response = JSON.stub(:parse, keyword_options_only_parse) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_equal({ "result" => { "tools" => [] } }, response) + end + def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers # SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header` # declarations, and the following `tools/call` mirrors the annotated arguments into From d40d9018d51c2137928d12e480aec3a9848fa0e1 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 8 Sep 2026 17:15:26 +0900 Subject: [PATCH 233/247] Keep json below 3.0 on Ruby 2.7.0 through 2.7.2 in the Gemfile ## Motivation and Context json 3.0 declares Ruby 2.7 as its floor, but its `load_file` forwards arguments after a leading parameter, `def load_file(filespec, ...)`, syntax Ruby 2.7.3 was the first to parse. On Ruby 2.7.0 through 2.7.2, requiring json 3.0.1 is a `SyntaxError`, so `require "mcp"` fails before any test runs, and the CI job pinned to 2.7.0, the minimum the gemspec supports, fails the whole suite at load time. The Gemfile keeps json below 3.0 on those three patch levels only. Later 2.7 releases and Ruby 3 parse the syntax and stay on whatever json the resolver picks. The line carries a FIXME to drop it once a json release includes ruby/json#1072, whichever version that turns out to be; excluding only the two broken releases would silently trust a 3.0.2 that may not carry the fix. ## How Has This Been Tested? `gem "json", "3.0.1"; require "json"` raises the `SyntaxError` on Ruby 2.7.0 and loads on 2.7.3 and 2.7.8. With the pin, a fresh `bundle install` on Ruby 2.7.2 resolves json 2.x and `bundle exec rake test` passes. ## Breaking Changes None. The Gemfile is not part of the gem. --- Gemfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 0aa50ee..fd061bd 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,9 @@ gem "activesupport" gem "debug" if RUBY_VERSION >= "3.1" # Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735). gem "i18n", "!= 1.15.0" +# FIXME: Drop this once a json release includes https://github.com/ruby/json/pull/1072. json 3.0.0 and 3.0.1 +# forward arguments after a leading parameter, syntax Ruby 2.7.3 was the first to parse, while allowing Ruby 2.7.0. +gem "json", "< 3" if RUBY_VERSION < "2.7.3" gem "rake", "~> 13.0" gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0" gem "yard", "~> 0.9" From 3b44ec4f488f3d38271de0631c773cf1c3d45c88 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 9 Sep 2026 10:43:15 +0900 Subject: [PATCH 234/247] Release 1.5.1 This release keeps the HTTP client working with json 3.0, which accepts the options of `JSON.parse` as keywords only. Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash that Ruby 3 no longer converts, so every JSON body the client received failed as an internal error. The client now parses response bodies itself, the way it already handled streamed ones, and no longer registers the middleware. The one visible difference: a malformed JSON body delivered whole by an adapter without streaming support now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did, instead of `:internal_error` wrapping a `Faraday::ParsingError`. --- CHANGELOG.md | 13 +++++++++++++ lib/mcp/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 051f239..88ec62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2026-09-09 + +This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse` +as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash, +which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error. +The client now parses response bodies itself, the way it already handled streamed ones, and no longer +registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support +now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did. + +### Fixed + +- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546) + ## [1.5.0] - 2026-09-05 This release makes the client answer a server's `ping` with the empty result the specification requires. diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 1bbb8dd..2d0dff8 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.5.0" + VERSION = "1.5.1" end From 4192ea58eae515fce6304e1731f2c2c4cdaa1b39 Mon Sep 17 00:00:00 2001 From: Junyuan Zheng Date: Wed, 9 Sep 2026 12:14:21 -0400 Subject: [PATCH 235/247] fix(client/oauth): omit body client_id when using HTTP Basic --- lib/mcp/client/oauth/flow.rb | 49 ++++++++++++++++-------------- test/mcp/client/oauth/flow_test.rb | 49 +++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 9a825c0..5b4c0c1 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -997,9 +997,9 @@ module MCP post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) end - # Submits a form-encoded request to the token endpoint, applying - # the client authentication method advertised in `client_information` and - # adding `client_id` (and `client_secret` when not using HTTP Basic). + # Submits a form-encoded token request using the authentication method + # stored in `client_information`. The method determines whether client + # credentials belong in the form body, a Basic header, or a JWT assertion. def post_to_token_endpoint(as_metadata:, client_info:, form:) client_id = client_info_required_value(client_info, "client_id") unless client_id @@ -1010,12 +1010,13 @@ module MCP client_secret = client_info_required_value(client_info, "client_secret") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") - form = if token_endpoint_auth_method == "private_key_jwt" - # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of - # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046). - # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is - # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity). - # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated. + # Apply one client authentication method per request (RFC 6749 Section 2.3). + headers = {} + form = case token_endpoint_auth_method + when "private_key_jwt" + # The assertion identifies the client through its `iss` and `sub` + # claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2). + # Use the issuer already checked by `ensure_issuer_matches!` as the audience. unless @provider.respond_to?(:client_assertion) raise AuthorizationError, "token_endpoint_auth_method is private_key_jwt but the provider does not " \ @@ -1026,22 +1027,24 @@ module MCP "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), ) + when "client_secret_post" + # Send the client ID and available secret in the form body. + body = form.merge("client_id" => client_id) + body["client_secret"] = client_secret if client_secret + body else - form.merge("client_id" => client_id) - end - - headers = {} - if client_secret - case token_endpoint_auth_method - when "client_secret_post" - form["client_secret"] = client_secret - when "none" - # Public client; no credential. - else - # RFC 6749 §2.3.1 recommends Basic for confidential clients and - # both Python and TypeScript SDKs default here when - # the authentication method is not explicitly stored. + if client_secret && token_endpoint_auth_method != "none" + # Basic is also the fallback when a secret is present but no method + # is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1); + # omit it because some servers treat it alongside Basic as a second + # authentication method and reject the request with `invalid_request`. headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret) + form + else + # With `none` or no secret, identify the client using `client_id`. + # This is required for unauthenticated authorization-code exchanges + # (RFC 6749 Section 3.2.1). + form.merge("client_id" => client_id) end end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index dc78e03..326dbd8 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -158,6 +158,21 @@ module MCP end end + def test_run_client_credentials_with_client_secret_basic_omits_client_id_from_body + # Explicit Basic authentication must keep both credentials in the header + # to avoid servers treating body credentials as a second auth method. + provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") && + !form.key?("client_id") && + !form.key?("client_secret") + end + end + def test_run_client_credentials_raises_clean_error_when_client_information_missing # The constructor always stores credentials, but if a custom storage loses them # the grant must fail with a domain error rather than a `NoMethodError` from @@ -1860,8 +1875,10 @@ module MCP expected = "Basic " + Base64.strict_encode64("pre-registered-client:pre-registered-secret") assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h req.headers["Authorization"] == expected && - !URI.decode_www_form(req.body).to_h.key?("client_secret") + !form.key?("client_id") && + !form.key?("client_secret") end end @@ -2365,6 +2382,36 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end + def test_refresh_with_client_secret_basic_omits_client_id_from_body + # Stored credentials without an auth method default to Basic. Refresh + # must also omit the body `client_id` when using that fallback. + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("conf-client:conf-secret") && + !form.key?("client_id") && + !form.key?("client_secret") + end + end + def test_run_records_the_issuer_that_minted_the_tokens state_holder = {} provider = Provider.new( From 2f65a6e502e104bddab43d13786b9f3cbd474be7 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 9 Sep 2026 18:36:20 +0900 Subject: [PATCH 236/247] Drop the json pin for Ruby 2.7.0 through 2.7.2 from the Gemfile ## Motivation and Context json 3.0.2 loads on Ruby 2.7.0 through 2.7.2 again: it no longer forwards arguments after a leading parameter, the syntax those patch levels could not parse, so the Gemfile no longer has to keep json below 3.0 there, and the CI job pinned to 2.7.0, the minimum the gemspec supports, resolves json 3.0.2 like every other job. The maintainers intend to raise json's own Ruby floor to 2.7.3 in the release after 3.0.2, so those patch levels stay on 3.0.2 by json's gemspec from then on, and no pin of this Gemfile's own is needed for that either. ## How Has This Been Tested? A fresh `bundle install` on Ruby 2.7.2 resolves json 3.0.2, `require "json"` loads it there, and `bundle exec rake test` passes. Ruby 3.0 and later were never affected by the pin. ## Breaking Changes None. The Gemfile is not part of the gem. --- Gemfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Gemfile b/Gemfile index fd061bd..0aa50ee 100644 --- a/Gemfile +++ b/Gemfile @@ -19,9 +19,6 @@ gem "activesupport" gem "debug" if RUBY_VERSION >= "3.1" # Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735). gem "i18n", "!= 1.15.0" -# FIXME: Drop this once a json release includes https://github.com/ruby/json/pull/1072. json 3.0.0 and 3.0.1 -# forward arguments after a leading parameter, syntax Ruby 2.7.3 was the first to parse, while allowing Ruby 2.7.0. -gem "json", "< 3" if RUBY_VERSION < "2.7.3" gem "rake", "~> 13.0" gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0" gem "yard", "~> 0.9" From 7e507f2963a24709c5c62800bf1d5ccd8cc10736 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 12 Sep 2026 00:02:30 +0900 Subject: [PATCH 237/247] [Doc] Document the client's ping answers and the stdio request limit ## Motivation and Context The client answers server pings automatically, but the docs leave out where that applies and what the stdio transport does with the other server-to-client requests. The client Transports page now lists the automatic ping answer among the things the stdio transport handles, and a note states that `ping` is the only server-to-client request answered there: a wire-level `elicitation/create` or `sampling/createMessage` is ignored over stdio, so a server that sends one waits for an answer that never comes, while the SEP-2322 `input_required` route works on every transport and keeps `on_elicitation` and `on_sampling` handlers working over stdio. Without this note, the only trace of the limitation is a hung tool call. The client Ping page now says that over HTTP a ping reaches the client only on a stream that is already open and that the client never opens one to receive pings, so a server can only ping a client that has a stream open. Registering `on_server_request("ping")` moves to the end of the section as the advanced option it is, together with its side effect of opening the GET listening stream on `connect`. The client overview mentions the automatic answer next to `MCP::Client#ping`, and the server Ping page's Client Side section points at it. ## How Has This Been Tested? Documentation only; the linked headings and pages exist. ## Breaking Changes None. --- docs/_client/index.md | 3 ++- docs/_client/ping.md | 8 ++++++-- docs/_client/transports.md | 8 ++++++++ docs/_server/ping.md | 2 ++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/_client/index.md b/docs/_client/index.md index c919622..2e3077c 100644 --- a/docs/_client/index.md +++ b/docs/_client/index.md @@ -17,7 +17,8 @@ This class supports: - Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle when the server serves it; see [Lifecycle](/client/lifecycle/) - Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery) -- Liveness check via the `ping` method (`MCP::Client#ping`) +- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings; + see [Answering Server Pings](/client/ping/#answering-server-pings) - Tool listing via the `tools/list` method (`MCP::Client#tools`) - Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) - Resource listing via the `resources/list` method (`MCP::Client#resources`) diff --git a/docs/_client/ping.md b/docs/_client/ping.md index f6889e9..f546afe 100644 --- a/docs/_client/ping.md +++ b/docs/_client/ping.md @@ -32,13 +32,17 @@ propagate as exceptions raised by the transport layer. ## Answering Server Pings On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with -the empty result - no handler is needed. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces -the automatic answer. +the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open +(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings. Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:` on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits. +To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces +the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`, +as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page. + ## Server Side How servers answer `ping` requests and ping the client themselves is documented on diff --git a/docs/_client/transports.md b/docs/_client/transports.md index a30259c..4362666 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -59,6 +59,14 @@ The stdio transport automatically handles: - Spawning the server process with `Open3.popen3` - MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) - JSON-RPC 2.0 message framing over newline-delimited JSON +- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings) + +{: .note } +> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create` +> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer +> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route, +> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation` +> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/). ## HTTP Transport Layer diff --git a/docs/_server/ping.md b/docs/_server/ping.md index 6272062..d8c6627 100644 --- a/docs/_server/ping.md +++ b/docs/_server/ping.md @@ -48,3 +48,5 @@ Server-to-client requests are bounded by a timeout on the Streamable HTTP transp ## Client Side Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page. +The Ruby client answers the pings sent with `ServerSession#ping` automatically; +see [Answering Server Pings](/client/ping/#answering-server-pings). From ef3eb7b0b2c41e20a6a956ec0c44eb61a9144544 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 13 Sep 2026 17:10:12 +0900 Subject: [PATCH 238/247] [Doc] State the Listen Stream Delivery Semantics The Subscriptions page documented the acknowledgement-first contract but not what delivery means: the stream carries only changes that happen after the acknowledgement, nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed. The Python SDK's subscriptions page states the same contract ("It is not a replay log"). Spelling it out gives client implementers the fetch-after-subscribing guidance and makes the acknowledgement-window behavior explicit. --- docs/_server/subscriptions.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/_server/subscriptions.md b/docs/_server/subscriptions.md index 9777d8c..22116aa 100644 --- a/docs/_server/subscriptions.md +++ b/docs/_server/subscriptions.md @@ -49,6 +49,13 @@ The first SSE event on the stream is the acknowledgement: } ``` +## Delivery Semantics + +A stream delivers only the changes that happen after its acknowledgement; it is not a replay log. +Nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed. +Clients fetch the current state after subscribing and treat each notification as a cue to refetch, +rather than as a payload. + ## Transport Support The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. From 8d1d5be4bad5a6b85b1e6cda8d1c1f7c0d739b68 Mon Sep 17 00:00:00 2001 From: Junyuan Zheng Date: Mon, 14 Sep 2026 09:45:38 -0400 Subject: [PATCH 239/247] fix(client/oauth): preserve token endpoint error diagnostics --- docs/_client/authorization.md | 21 ++++ lib/mcp/client/oauth/flow.rb | 58 ++++++++--- test/mcp/client/oauth/flow_test.rb | 156 ++++++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 17 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index a673a19..750e0c6 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -159,6 +159,27 @@ provider = MCP::Client::OAuth::Provider.new( ) ``` +### Token Endpoint Errors + +When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and +the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2). +For example: + +```text +Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods +``` + +The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string +diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers. +An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token +recovery behavior is unchanged. + +Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when +truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed. +The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors. +Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging +and redaction policy before persisting them or displaying them to users. + ### Client Credentials Grant For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 5b4c0c1..acc0f1c 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -14,7 +14,19 @@ module MCP # `Provider`; this class consumes a Provider plus signal data extracted from # the failing response (resource_metadata URL, scope challenge). class Flow - class AuthorizationError < StandardError; end + TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 + TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512 + + class AuthorizationError < StandardError + attr_reader :http_status, :error, :error_description + + def initialize(message = nil, http_status: nil, error: nil, error_description: nil) + super(message) + @http_status = http_status + @error = error + @error_description = error_description + end + end # Raised specifically when the token endpoint rejects a grant with # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to @@ -1062,11 +1074,7 @@ module MCP end if response.status < 200 || response.status >= 300 - if token_endpoint_error_code(response) == "invalid_grant" - raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant." - end - - raise AuthorizationError, "Token endpoint returned status #{response.status}." + raise token_endpoint_error(response) end parsed = begin @@ -1087,17 +1095,35 @@ module MCP parsed end - # Extracts the `error` code from an RFC 6749 §5.2 error response body - # when one is parseable. Returns nil on any parse failure or when - # the body is not JSON. - def token_endpoint_error_code(response) - body = response_body_string(response).to_s - return if body.empty? + # Surface only RFC 6749 §5.2 diagnostic fields, never the raw response, + # which may contain tokens or other credentials. Classify the original + # code so sanitization cannot turn malformed input into invalid_grant. + def token_endpoint_error(response) + message = "Token endpoint returned status #{response.status}." + error_class = AuthorizationError + parsed = JSON.parse(response_body_string(response)) + parsed = {} unless parsed.is_a?(Hash) - parsed = JSON.parse(body) - parsed["error"] if parsed.is_a?(Hash) - rescue JSON::ParserError - nil + error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError + error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) + description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) + message += " #{[error, description].compact.join(": ")}" if error || description + + error_class.new(message, http_status: response.status, error: error, error_description: description) + rescue StandardError + # Diagnostics must not mask the endpoint failure or change refresh recovery. + error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status) + end + + def token_endpoint_diagnostic(value, limit:) + return unless value.is_a?(String) + + # RFC 6749 permits printable ASCII except double quotes and backslashes. + # Replace other characters to keep provider text on one log line. + value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip + return if value.empty? + + value.length > limit ? "#{value[0, limit - 3]}..." : value end # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 326dbd8..5daff12 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -2731,7 +2731,161 @@ module MCP provider.save_client_information("client_id" => "test-client") provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") - assert_raises(Flow::InvalidGrantError) do + error = assert_raises(Flow::InvalidGrantError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("refresh token expired", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: refresh token expired", error.message) + end + + def test_token_exchange_preserves_oauth_diagnostics + stub_request(:post, "#{@auth_base}/token").to_return( + status: 400, + body: JSON.generate( + error: "invalid_request", + error_description: "Client must not use multiple authentication methods", + access_token: "do-not-log-access-token", + refresh_token: "do-not-log-refresh-token", + client_secret: "do-not-log-client-secret", + error_uri: "https://auth.example.com/error?secret=do-not-log", + ), + ) + + error = assert_raises(Flow::AuthorizationError) do + capture_authorization_scope(grant_types: ["authorization_code"]) + end + + assert_equal(400, error.http_status) + assert_equal("invalid_request", error.error) + assert_equal("Client must not use multiple authentication methods", error.error_description) + assert_equal( + "Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods", + error.message, + ) + end + + def test_refresh_preserves_oauth_diagnostics + error = refresh_token_endpoint_error( + JSON.generate(error: "invalid_client", error_description: "Client authentication failed"), + status: 401, + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(401, error.http_status) + assert_equal("invalid_client", error.error) + assert_equal("Client authentication failed", error.error_description) + assert_equal("Token endpoint returned status 401. invalid_client: Client authentication failed", error.message) + end + + def test_token_endpoint_errors_fall_back_for_malformed_bodies + ["", "secret", "{broken", "null", "[]", '"secret"', "42"].each do |body| + error = refresh_token_endpoint_error(body) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(400, error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_ignore_non_string_and_empty_fields + [nil, 42, true, [], { secret: "hidden" }, "", " \r\n\t"].each do |value| + error = refresh_token_endpoint_error(JSON.generate(error: value, error_description: value)) + + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_preserve_optional_fields_independently + error = refresh_token_endpoint_error(JSON.generate(error: "provider_extension")) + assert_equal("provider_extension", error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400. provider_extension", error.message) + + error = refresh_token_endpoint_error(JSON.generate(error_description: "Details without a code")) + assert_nil(error.error) + assert_equal("Details without a code", error.error_description) + assert_equal("Token endpoint returned status 400. Details without a code", error.message) + end + + def test_token_endpoint_errors_sanitize_without_changing_grant_classification + error = refresh_token_endpoint_error( + JSON.generate(error: "invalid_grant\n", error_description: "expired\r\n\t\e\u0000\"\\\u2028token"), + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal("invalid_grant", error.error) + assert_equal("expired token", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: expired token", error.message) + end + + def test_invalid_grant_with_invalid_utf8_description_preserves_classification + error = refresh_token_endpoint_error( + "{\"error\":\"invalid_grant\",\"error_description\":\"bad \xFF byte\"}".b, + ) + + assert_instance_of(Flow::InvalidGrantError, error) + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("bad byte", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) + end + + def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields + error = refresh_token_endpoint_error( + "{\"error\":\"invalid_grant\xFF\",\"error_description\":\"bad \xFF byte\"}".b, + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("bad byte", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) + end + + def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises + Flow.any_instance.stubs(:token_endpoint_diagnostic).raises(ArgumentError, "sensitive provider text") + + { "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass| + error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details")) + + assert_instance_of(klass, error) + assert_equal(400, error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_bound_diagnostic_lengths + error = refresh_token_endpoint_error(JSON.generate(error: "e" * 200, error_description: "d" * 1000)) + + assert_equal("#{"e" * 125}...", error.error) + assert_equal("#{"d" * 509}...", error.error_description) + assert_equal("Token endpoint returned status 400. #{error.error}: #{error.error_description}", error.message) + end + + def test_other_authorization_errors_have_no_token_endpoint_diagnostics + error = Flow::AuthorizationError.new("Discovery failed") + + assert_equal("Discovery failed", error.message) + assert_nil(error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + end + + def refresh_token_endpoint_error(body, status: 400) + stub_request(:post, "#{@auth_base}/token").to_return(status: status, body: body) + provider = ssrf_test_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::AuthorizationError) do Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) end end From 99ef8732feb46e79713e59ee63866bbba9687409 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 15 Sep 2026 12:33:24 +0900 Subject: [PATCH 240/247] Fix a redundant regexp escape in the tool name validation ## Motivation and Context This PR suppresses the following offense of RuboCop. ```console $ bundle exec rubocop Inspecting 163 files (snip) Offenses: lib/mcp/tool.rb:152:48: C: [Correctable] Style/RedundantRegexpEscape: Redundant escape inside regexp literal unless tool_name.match?(/\A[A-Za-z\d_\-\.]+\z/) ^^ 163 files inspected, 1 offense detected, 1 offense autocorrectable ``` ## How Has This Been Tested? `bundle exec rake` passes: RuboCop reports no offenses. ## Breaking Changes None. --- lib/mcp/tool.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 37c5d92..1b76b80 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -149,7 +149,7 @@ module MCP raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)." end - unless tool_name.match?(/\A[A-Za-z\d_\-\.]+\z/) + unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/) raise ArgumentError, <<~MESSAGE Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.). MESSAGE From 8ad80dd0b48fc7ad49befaea5e84f5b08e5bf1de Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 17 Sep 2026 00:52:10 +0900 Subject: [PATCH 241/247] Let a provider add parameters to the token requests it makes ## Motivation and Context `ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`, `resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all, and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides. RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes `token_request_params:`, a Hash of String keys and values added to every token request the provider makes, and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider` subclass that defines the method gets the same treatment on its authorization code exchange, refresh, or `jwt-bearer` request. `Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like `authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored. The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself (`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError` from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow, before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to the caller's Hash cannot alter what is sent. A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than `AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow, which would then fail the same way after the user signed in. Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys whose class is exactly `String`. Fixes #554. ## How Has This Been Tested? New tests cover every token request the parameters can ride (the `client_credentials` grant under all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`), both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method), and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow. All of them fail against the previous library. ## Breaking Changes None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends the same requests as before. --- docs/_client/authorization.md | 14 +- .../oauth/client_credentials_provider.rb | 18 +- .../client/oauth/cross_app_access_provider.rb | 15 +- lib/mcp/client/oauth/flow.rb | 64 +++++ lib/mcp/client/oauth/provider.rb | 9 +- .../client/oauth/storage_backed_provider.rb | 32 ++- .../oauth/client_credentials_provider_test.rb | 101 ++++++++ .../oauth/cross_app_access_provider_test.rb | 36 +++ test/mcp/client/oauth/flow_test.rb | 243 +++++++++++++++++- test/mcp/client/oauth/http_oauth_test.rb | 51 ++++ test/mcp/client/oauth/provider_test.rb | 24 ++ 11 files changed, 583 insertions(+), 24 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index 750e0c6..b35b597 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -112,6 +112,14 @@ Optional keyword arguments: served at the URL is a separate JSON artifact from the `client_metadata` keyword above: the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. +- `token_request_params`: Hash of String keys and values added to every token request the provider makes, + for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`. + Defaults to `nil`, which adds nothing. The authorization request is not affected. + A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity, + or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, + when the provider is built, and the accepted Hash is copied and frozen. + Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes; + the flow checks the returned value by the same rules and raises the same error before the token request is sent. {: .warning } > The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents, @@ -192,6 +200,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new( client_secret: ENV.fetch("MCP_CLIENT_SECRET"), # token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt" # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) + # token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires) ) transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) @@ -207,7 +216,8 @@ Keyword arguments: - `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` or `"RS256"`; `client_secret` must not be set, because the private key is the credential. -- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`. + Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`. ### Cross-App Access (JWT Bearer) Grant @@ -244,7 +254,7 @@ Keyword arguments: - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). Passing both through to `IDJAGTokenExchange.request` covers the common case. -- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`. ### Communication Security diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index 21276bb..22ef56a 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -37,11 +37,15 @@ module MCP # also take the algorithm as an explicit option). # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. - # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, - # `client_information`, and `save_client_information(info)`. Defaults to - # an `InMemoryStorage`. The `client_id` / `client_secret` are written - # into it so the token exchange reads them through the same path as - # a pre-registered authorization-code client. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, + # and `save_client_information(info)`. Defaults to an `InMemoryStorage`. + # The `client_id` / `client_secret` are written into it so the token exchange reads + # them through the same path as a pre-registered authorization-code client. + # - `token_request_params` - Hash of String keys and values added to every + # token request this provider makes, for parameters the authorization + # server requires beyond the grant itself (Auth0's `audience`, for example). + # A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`. + # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. class ClientCredentialsProvider include StorageBackedProvider @@ -61,7 +65,8 @@ module MCP signing_algorithm: nil, scope: nil, storage: nil, - authorization_request_validator: nil + authorization_request_validator: nil, + token_request_params: nil ) if blank?(client_id) raise InvalidCredentialsError, "client_id is required for the client_credentials grant." @@ -105,6 +110,7 @@ module MCP @scope = scope @storage = storage || InMemoryStorage.new @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) @storage.save_client_information(client_information) end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb index 67342c0..422cbb5 100644 --- a/lib/mcp/client/oauth/cross_app_access_provider.rb +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -25,6 +25,10 @@ module MCP # the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. # Defaults to an `InMemoryStorage`. + # - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters + # the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises + # `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen. + # See `StorageBackedProvider#token_request_params`. # # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 class CrossAppAccessProvider @@ -35,7 +39,15 @@ module MCP attr_reader :scope, :storage - def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil) + def initialize( + client_id:, + client_secret:, + assertion_provider:, + scope: nil, + storage: nil, + authorization_request_validator: nil, + token_request_params: nil + ) if blank?(client_id) raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." end @@ -52,6 +64,7 @@ module MCP @scope = scope @storage = storage || InMemoryStorage.new @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) @storage.save_client_information( "client_id" => client_id, "client_secret" => client_secret, diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index acc0f1c..1093eba 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -17,6 +17,23 @@ module MCP TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512 + # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`, + # so a provider naming one of these is refused rather than left believing its value was sent. + RESERVED_TOKEN_REQUEST_PARAMS = [ + "grant_type", + "client_id", + "client_secret", + "client_assertion", + "client_assertion_type", + "scope", + "resource", + "code", + "code_verifier", + "redirect_uri", + "refresh_token", + "assertion", + ].freeze + class AuthorizationError < StandardError attr_reader :http_status, :error, :error_description @@ -40,6 +57,33 @@ module MCP # or authorization server metadata failure by rescuing a class rather than by matching the message text. class AuthorizationRefusedError < AuthorizationError; end + # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity, + # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake, + # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on + # a failed refresh as a reason to run the interactive flow. + class InvalidTokenRequestParamsError < ArgumentError; end + + class << self + # Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can. + # Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`, + # so the same problem reads the same wherever it surfaces. + def token_request_params_problem(params) + return "must be a Hash (got #{params.class})." unless params.is_a?(Hash) + + # Two equal keys are two entries here, which would be sent twice from a provider method + # or silently collapse into one when the constructors copy the Hash. + return "must not compare keys by identity." if params.compare_by_identity? + + params.each do |key, value| + return "keys must be Strings (got #{key.class})." unless key.is_a?(String) + return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String) + return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key) + end + + nil + end + end + def initialize(provider:, http_client_factory: nil) @provider = provider @http_client_factory = http_client_factory || -> { default_http_client } @@ -960,6 +1004,22 @@ module MCP @provider.authorization_flow end + # Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them; + # Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method, + # or one returning `nil`, adds nothing. + # A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do. + def provider_token_request_params + return {} unless @provider.respond_to?(:token_request_params) + + params = @provider.token_request_params + return {} if params.nil? + + problem = self.class.token_request_params_problem(params) + raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem + + params + end + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint @@ -1012,6 +1072,9 @@ module MCP # Submits a form-encoded token request using the authentication method # stored in `client_information`. The method determines whether client # credentials belong in the form body, a Basic header, or a JWT assertion. + # A provider's `token_request_params` go underneath the flow's own parameters, + # which therefore win, and are refused before the request is sent when they name + # a reserved parameter or are not a Hash of Strings. def post_to_token_endpoint(as_metadata:, client_info:, form:) client_id = client_info_required_value(client_info, "client_id") unless client_id @@ -1021,6 +1084,7 @@ module MCP client_secret = client_info_required_value(client_info, "client_secret") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") + form = provider_token_request_params.merge(form) # Apply one client authentication method per request (RFC 6749 Section 2.3). headers = {} diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 7d1a0f6..16f8c4e 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -48,6 +48,11 @@ module MCP # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword: # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. + # - `token_request_params` - Hash of String keys and values added to every token request this provider makes + # (the authorization code exchange and refresh), for parameters the authorization server requires beyond + # the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` + # raises `Flow::InvalidTokenRequestParamsError`. + # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. class Provider include StorageBackedProvider @@ -87,7 +92,8 @@ module MCP scope: nil, storage: nil, client_id_metadata_document_url: nil, - authorization_request_validator: nil + authorization_request_validator: nil, + token_request_params: nil ) unless Discovery.secure_url?(redirect_uri) raise InsecureRedirectURIError, @@ -117,6 +123,7 @@ module MCP @storage = storage || InMemoryStorage.new @client_id_metadata_document_url = client_id_metadata_document_url @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) end # Identifies the OAuth flow this provider drives. diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb index 44d09a1..2be8acc 100644 --- a/lib/mcp/client/oauth/storage_backed_provider.rb +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -4,11 +4,11 @@ module MCP class Client module OAuth # Shared token/credential persistence for the OAuth provider classes - # (`Provider` for the authorization-code flow and `ClientCredentialsProvider` - # for the client_credentials flow). The two grants differ in how they authenticate, - # but both read and write the same two pieces of state through a `storage` object: - # the token response and the client information. This module supplies that delegation - # so the `Flow` orchestrator can treat any provider uniformly. + # (`Provider` for the authorization-code flow, `ClientCredentialsProvider` + # for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow). + # The grants differ in how they authenticate, but all read and write the same two pieces of state + # through a `storage` object: the token response and the client information. This module supplies + # that delegation so the `Flow` orchestrator can treat any provider uniformly. # # Including classes must set `@storage` to an object responding to `tokens`, # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` @@ -21,6 +21,13 @@ module MCP # MCP SDK has today. attr_reader :authorization_request_validator + # Optional Hash of String keys and values added to every token request the provider makes, for parameters + # the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them; + # Auth0's `audience` is the usual one). `nil` (the default) adds nothing. + # Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value + # with `Flow::InvalidTokenRequestParamsError`. + attr_reader :token_request_params + def access_token tokens&.dig("access_token") || tokens&.dig(:access_token) end @@ -44,6 +51,21 @@ module MCP def clear_tokens! @storage.save_tokens(nil) end + + private + + # Constructors call this before writing to `storage`, so a rejected provider leaves it untouched. + # The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key, + # or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly + # because `Hash` copies and freezes only keys whose class is exactly `String`. + def frozen_token_request_params(params) + return if params.nil? + + problem = Flow.token_request_params_problem(params) + raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem + + params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze + end end end end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index c112aba..5ff4d20 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -7,6 +7,23 @@ module MCP class Client module OAuth class ClientCredentialsProviderTest < Minitest::Test + # Every parameter the flow sets on a token request, spelled out rather than read from + # `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here. + RESERVED_TOKEN_REQUEST_PARAMS = [ + "grant_type", + "client_id", + "client_secret", + "client_assertion", + "client_assertion_type", + "scope", + "resource", + "code", + "code_verifier", + "redirect_uri", + "refresh_token", + "assertion", + ].freeze + def test_initialize_stores_credentials_as_client_information provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") @@ -160,6 +177,90 @@ module MCP assert_equal("cc-client", claims["sub"]) assert_equal("https://auth.example.com", claims["aud"]) end + + def test_token_request_params_is_nil_by_default + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + assert_nil(provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) + + params["audience"] << "/changed" + params["organization"] = "org_123" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + assert_predicate(provider.token_request_params["audience"], :frozen?) + end + + def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself + RESERVED_TOKEN_REQUEST_PARAMS.each do |key| + error = assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{key.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) + end + + assert_includes(error.message, key.inspect) + end + end + + def test_initialize_rejects_token_request_params_that_are_not_a_hash_of_strings + ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }].each do |params| + assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{params.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) + end + end + end + + def test_initialize_rejects_token_request_params_that_compare_keys_by_identity + # Two equal keys are two entries in such a Hash and would silently collapse into one when copied. + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_request_params: { "audience" => "x" }.compare_by_identity, + ) + end + + assert_match(/identity/, error.message) + end + + def test_initialize_copies_keys_that_hash_would_share_with_the_caller + # `Hash` copies and freezes only keys whose class is exactly `String`; a subclass key would otherwise + # stay shared with the caller, and a later `replace` would turn it into a reserved name. + key = Class.new(String).new("audience") + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) + + key.replace("grant_type") + + assert_equal({ "audience" => "x" }, provider.token_request_params) + refute_same(key, provider.token_request_params.keys.first) + assert_predicate(provider.token_request_params.keys.first, :frozen?) + end + + def test_initialize_keeps_empty_token_request_params_as_a_frozen_empty_hash + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: {}) + + assert_equal({}, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_writes_no_client_information_when_token_request_params_are_rejected + storage = InMemoryStorage.new + + assert_raises(Flow::InvalidTokenRequestParamsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + storage: storage, + token_request_params: { "grant_type" => "password" }, + ) + end + + assert_nil(storage.client_information) + end end end end diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb index deb7e4b..1283b62 100644 --- a/test/mcp/client/oauth/cross_app_access_provider_test.rb +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -88,6 +88,42 @@ module MCP provider.clear_tokens! assert_nil(provider.tokens) end + + def test_token_request_params_is_nil_by_default + assert_nil(build_provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + token_request_params: params, + ) + + params["audience"] << "/changed" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_writes_no_client_information_when_token_request_params_are_rejected + storage = InMemoryStorage.new + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + storage: storage, + token_request_params: { "assertion" => "x" }, + ) + end + + assert_includes(error.message, '"assertion"') + assert_nil(storage.client_information) + end end end end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5daff12..c463fdc 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -85,14 +85,62 @@ module MCP ) end - def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil) ClientCredentialsProvider.new( client_id: "cc-client", client_secret: "cc-secret", token_endpoint_auth_method: token_endpoint_auth_method, + token_request_params: token_request_params, ) end + # An authorization-code provider that drives the code exchange and refresh with the given parameters. + def provider_with_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) + Provider.new(**authorization_code_provider_arguments(redirect_handler, callback_handler), token_request_params: params) + end + + # Overriding the reader bypasses the constructor's validation, leaving the flow's own check to be exercised. + def provider_returning_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) + provider_class = Class.new(Provider) do + define_method(:token_request_params) { params } + end + + provider_class.new(**authorization_code_provider_arguments(redirect_handler, callback_handler)) + end + + def authorization_code_provider_arguments(redirect_handler, callback_handler) + { + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: redirect_handler, + callback_handler: callback_handler, + } + end + + # See `provider_returning_token_request_params`. + def client_credentials_provider_returning(token_request_params:) + provider_class = Class.new(ClientCredentialsProvider) do + define_method(:token_request_params) { token_request_params } + end + + provider_class.new(client_id: "cc-client", client_secret: "cc-secret") + end + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); + # fall back to the pre-3.0 API on older Rubies. + def generate_es256_key + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + end + # Runs the full authorization flow and returns the `scope` query parameter # sent on the authorization request. The caller stubs the AS metadata; # this helper supplies a provider whose `grant_types` and optional pre-set @@ -250,17 +298,10 @@ module MCP ), ) - # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); - # fall back to the pre-3.0 API on older Rubies. - key = if OpenSSL::PKey::EC.respond_to?(:generate) - OpenSSL::PKey::EC.generate("prime256v1") - else - OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) - end provider = ClientCredentialsProvider.new( client_id: "cc-client", token_endpoint_auth_method: "private_key_jwt", - private_key: key, + private_key: generate_es256_key, signing_algorithm: "ES256", ) @@ -285,6 +326,190 @@ module MCP end end + def test_run_client_credentials_sends_token_request_params_with_client_secret_basic + provider = client_credentials_provider(token_request_params: { "audience" => "https://api.example.com" }) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "client_credentials" && + form["resource"] == "https://srv.example.com/mcp" && + form["audience"] == "https://api.example.com" && + !form.key?("client_id") && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") + end + end + + def test_run_client_credentials_sends_token_request_params_returned_by_a_provider_method + provider = client_credentials_provider_returning(token_request_params: { "audience" => "https://api.example.com" }) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["audience"] == "https://api.example.com" + end + end + + def test_run_client_credentials_sends_the_same_request_for_empty_token_request_params + provider = client_credentials_provider(token_request_params: {}) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).map(&:first) == ["grant_type", "resource"] + end + end + + def test_run_client_credentials_sends_token_request_params_with_client_secret_post + provider = client_credentials_provider( + token_endpoint_auth_method: "client_secret_post", + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["audience"] == "https://api.example.com" && + form["client_id"] == "cc-client" && + form["client_secret"] == "cc-secret" + end + end + + def test_run_client_credentials_sends_token_request_params_with_private_key_jwt + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["audience"] == "https://api.example.com" && + form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + !form["client_assertion"].to_s.empty? + end + end + + def test_run_sends_token_request_params_on_the_authorization_code_exchange + state_value = nil + provider = provider_with_token_request_params( + { "audience" => "https://api.example.com" }, + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" && + form["audience"] == "https://api.example.com" + end + end + + def test_refresh_sends_token_request_params + provider = provider_with_token_request_params({ "audience" => "https://api.example.com" }) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + form["audience"] == "https://api.example.com" + end + end + + def test_run_sends_token_request_params_on_the_jwt_bearer_grant + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["audience"] == "https://api.example.com" + end + end + + def test_run_refuses_token_request_params_that_name_a_reserved_parameter + provider = client_credentials_provider_returning(token_request_params: { "grant_type" => "password" }) + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"grant_type"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_refuses_token_request_params_that_are_not_a_hash_of_strings + shapes = ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }, { "audience" => "x" }.compare_by_identity] + shapes.each do |params| + provider = client_credentials_provider_returning(token_request_params: params) + + assert_raises(Flow::InvalidTokenRequestParamsError, "should refuse #{params.inspect}") do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_refuses_token_request_params_that_name_a_reserved_parameter + # Not an `AuthorizationError`: the transport treats a failed refresh as a reason + # to run the interactive flow, which would fail the same way afterwards. + provider = provider_returning_token_request_params({ "refresh_token" => "other" }) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"refresh_token"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_refuses_token_request_params_on_the_authorization_code_exchange + state_value = nil + provider = provider_returning_token_request_params( + { "code" => "other" }, + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"code"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider # SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at # the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved. diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 9970971..5b1be13 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -627,6 +627,57 @@ module MCP assert_equal("refreshed-token", provider.access_token) end + def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing + # A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized: + # the refresh attempt must raise rather than fall through to the interactive flow, + # which would fail the same way after the user signed in. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + redirected = false + provider_class = Class.new(Provider) do + define_method(:token_request_params) { { "grant_type" => "password" } } + end + provider = provider_class.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { redirected = true }, + callback_handler: -> { ["code", "state"] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(Flow::InvalidTokenRequestParamsError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + refute(redirected) + assert_not_requested(:post, "#{@auth_base}/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure # A 5xx (or any non-`invalid_grant`) from the token endpoint indicates # a transient AS outage, NOT that the refresh token is dead. diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index 3db9427..d9fa64e 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -187,6 +187,30 @@ module MCP assert_equal(:authorization_code, provider.authorization_flow) end + + def test_token_request_params_is_nil_by_default + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_nil(provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = Provider.new(**args_for("https://app.example.com/callback"), token_request_params: params) + + params["audience"] << "/changed" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Provider.new(**args_for("https://app.example.com/callback"), token_request_params: { "code" => "x" }) + end + + assert_includes(error.message, '"code"') + end end end end From 47eb74aa7aea5db27704e815a08a9082cd4b727b Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 17 Sep 2026 12:30:05 +0900 Subject: [PATCH 242/247] Validate the issuer of legacy authorization server metadata ## Motivation and Context On the legacy 2025-03-26 path, taken when a server publishes no Protected Resource Metadata, the authorization server metadata is fetched from the MCP server's origin, which that spec names as the authorization base URL, but its `issuer` was never compared with that origin. RFC 8414 Section 3.3 requires the comparison, the 2025-03-26 spec makes RFC 8414 a MUST for clients, and the TypeScript and Python SDKs both compare on this path, tolerating only a trailing slash. The flow nevertheless used the unverified `issuer` as the authorization server's identity everywhere: tokens were stamped with it, stored client information was matched against it, the `private_key_jwt` and ID-JAG assertions took it as their audience, and `authorization_request_validator` was shown it. A server on that path could therefore claim any issuer. Claiming the one that minted the stored tokens made `refresh!` send the refresh token and the stored client secret to the server's own token endpoint, claiming the one that issued stored client information made the flow reuse those credentials there, and a validator allow-listing the claimed issuer approved the request. The legacy path now applies the same check as the modern one: the document's `issuer` must be the origin, rendered with or without a trailing slash, and any other document is refused before registration, redirect, or token request. A server without a metadata document still gets the spec's default endpoints. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` serve legacy metadata at the MCP server origin that claims `https://auth.example.com` and check that the flow, a refresh of tokens issued by that server, and a flow holding client information bound to it are all refused before any request, with the validator never asked and the credentials never presented. Metadata naming the origin, with or without a trailing slash and with endpoints under a path prefix, completes the flow, including the RFC 9207 `iss` check. The former test of a path-prefix issuer now expects the refusal. The refusal tests fail against the previous library. ## Breaking Changes A server without Protected Resource Metadata whose authorization server metadata names an issuer other than its origin is refused instead of used. The TypeScript and Python SDKs already refuse such a server, and the 2025-03-26 spec places the metadata at the origin. --- docs/_client/authorization.md | 2 +- lib/mcp/client/oauth/flow.rb | 28 +++++- test/mcp/client/oauth/flow_test.rb | 150 ++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 11 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index 750e0c6..e3c0709 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -37,7 +37,7 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. - Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts - as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), + as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3), and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index acc0f1c..fdbf009 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -361,16 +361,26 @@ module MCP # Fetches and validates the authorization server's RFC 8414 metadata. # - # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3). - # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement, - # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from - # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path). + # The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths. + # On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as + # the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs + # accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer` + # would otherwise become the identity tokens and client information are bound to, assertions are minted for, + # and the validator is shown, so a server could claim another authorization server and unlock the credentials + # bound to it. # When even the metadata document is absent, the legacy spec's default endpoints are used. def authorization_server_metadata(authorization_server:, legacy:, server_url:) metadata = if legacy - begin + fetched = begin fetch_authorization_server_metadata(issuer_url: authorization_server) rescue AuthorizationError + nil + end + + if fetched + ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"]) + fetched + else default_legacy_metadata(authorization_server) end else @@ -415,6 +425,14 @@ module MCP fetch_metadata_json(urls, label: "authorization server metadata") end + # The legacy authorization base is an origin, which a document may render as `https://host/`; + # both name the same server, and nothing else does. + def ensure_legacy_issuer_matches!(expected:, returned:) + return if returned == "#{expected}/" + + ensure_issuer_matches!(expected: expected, returned: returned) + end + # Reads `authorization_servers` from a PRM document and returns # the first entry, raising `AuthorizationError` for any of the malformed # shapes a non-compliant server could emit (missing field, non-Array diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5daff12..61d7559 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -917,22 +917,22 @@ module MCP stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) end - def test_run_falls_back_to_server_origin_metadata_without_prm - # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin, - # OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin. - # The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it). + # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin with the given `issuer` + # and the OAuth endpoints under a path prefix. + def stub_legacy_metadata_with_prefixed_endpoints(issuer:, iss_supported: false) stub_prm_not_found stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( - issuer: "https://srv.example.com/oauth", + issuer: issuer, authorization_endpoint: "https://srv.example.com/oauth/authorize", token_endpoint: "https://srv.example.com/oauth/token", registration_endpoint: "https://srv.example.com/oauth/register", response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], + authorization_response_iss_parameter_supported: iss_supported, ), ) stub_request(:post, "https://srv.example.com/oauth/register").to_return( @@ -945,6 +945,11 @@ module MCP headers: { "Content-Type" => "application/json" }, body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), ) + end + + def test_run_falls_back_to_server_origin_metadata_without_prm + # The document names the origin, so the RFC 8414 Section 3.3 check passes and its prefixed endpoints are used. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com") holder = {} provider = build_legacy_discovery_provider(holder) @@ -953,11 +958,54 @@ module MCP assert_equal(:authorized, result) assert_equal("legacy-token", provider.access_token) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) assert_equal("/oauth/authorize", holder[:authorization_url].path) assert_requested(:post, "https://srv.example.com/oauth/register") assert_requested(:post, "https://srv.example.com/oauth/token") end + def test_run_accepts_legacy_metadata_naming_the_origin_with_a_trailing_slash + # A root issuer rendered as `https://host/` names the same server; the TypeScript and Python SDKs accept it too, + # and the document's spelling is what the RFC 9207 `iss` and the recorded issuer carry. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/", iss_supported: true) + + holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", holder[:state], "https://srv.example.com/"] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("https://srv.example.com/", provider.tokens["issuer"]) + end + + def test_run_refuses_legacy_metadata_whose_issuer_is_not_the_origin + # RFC 8414 Section 3.3 applies on the legacy path as well: the 2025-03-26 spec places the metadata + # at the origin, so an issuer under a path prefix is a mismatch, as it is for the TypeScript and Python SDKs. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/oauth") + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_nil(holder[:authorization_url]) + assert_not_requested(:post, "https://srv.example.com/oauth/register") + assert_not_requested(:post, "https://srv.example.com/oauth/token") + end + def test_run_falls_back_to_default_endpoints_without_any_metadata # Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata, # the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256. @@ -1032,6 +1080,98 @@ module MCP assert_match(/`issuer` does not match/, error.message) end + # Legacy metadata served at the MCP server origin that claims the identity of another authorization server, + # with every endpoint at the origin itself. The claim fails the RFC 8414 check, so none of these endpoints is reached. + def stub_legacy_metadata_claiming(issuer) + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: issuer, + authorization_endpoint: "https://srv.example.com/authorize", + token_endpoint: "https://srv.example.com/token", + registration_endpoint: "https://srv.example.com/register", + code_challenge_methods_supported: ["S256"], + ), + ) + stub_request(:post, "https://srv.example.com/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + end + + def test_run_refuses_legacy_metadata_claiming_another_authorization_server_before_the_validator + stub_legacy_metadata_claiming(@auth_base) + recorder = [] + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { recorder << :redirected }, + callback_handler: -> { ["test-auth-code", "state"] }, + authorization_request_validator: ->(request) { + recorder << request + true + }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_empty(recorder) + assert_not_requested(:post, "https://srv.example.com/register") + assert_not_requested(:post, "https://srv.example.com/token") + end + + def test_refresh_refuses_legacy_metadata_claiming_the_issuer_that_minted_the_tokens + # The tokens came from https://auth.example.com; a PRM-less server claiming that issuer at its own origin + # must not receive them, so the refresh token stays for the server that issued it. + stub_legacy_metadata_claiming(@auth_base) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_run_does_not_present_client_information_bound_elsewhere_when_legacy_metadata_claims_that_issuer + # Client information issued by https://auth.example.com is bound to it (SEP-2352); a PRM-less server claiming + # that issuer is refused before the credentials could be presented anywhere. + stub_legacy_metadata_claiming(@auth_base) + holder = {} + provider = build_legacy_discovery_provider(holder) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret", "issuer" => @auth_base) + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_nil(holder[:authorization_url]) + assert_not_requested(:post, "https://srv.example.com/register") + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("conf-secret", provider.client_information["client_secret"]) + assert_equal(@auth_base, provider.client_information["issuer"]) + end + def test_run_raises_when_prm_authorization_servers_is_not_an_array # `authorization_servers` MUST be an Array per RFC 9728. A misbehaving # PRM that returns a String would otherwise reach `.first` and raise From 29e8d3f2b91259f526c66329dff08a697d2fcf6f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 17 Sep 2026 19:05:51 +0900 Subject: [PATCH 243/247] Let the client_credentials and jwt-bearer grants run without Protected Resource Metadata ## Motivation and Context When a server publishes no Protected Resource Metadata, discovery takes the legacy 2025-03-26 path and `prm` is `nil`. The authorization-code branch of `run!` allowed for that by passing `prm || {}` to `resolve_scope`, but `run_client_credentials!` and `run_jwt_bearer!` passed `prm` as is, and `resolve_scope` then called `prm["scopes_supported"]` on `nil`. Without a non-empty challenge scope, a `ClientCredentialsProvider` or `CrossAppAccessProvider` pointed at such a server therefore failed with `NoMethodError` after discovery and before the token request or the ID-JAG callback, instead of running its grant against the legacy authorization base the way `Provider` does. `resolve_scope` now treats a missing PRM as advertising no scopes, so the challenge scope or the provider's own scope applies, and the authorization-code branch passes `prm` unchanged like the other two. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` run the `client_credentials` grant against a PRM-less server with metadata at the origin, checking the provider's scope, the `resource`, and the recorded issuer, and run the `jwt-bearer` grant against a server without any metadata, checking the default token endpoint, the assertion audience, the `resource`, and the recorded issuer. Both raise `NoMethodError` against the previous library. ## Breaking Changes None. --- lib/mcp/client/oauth/flow.rb | 5 ++- test/mcp/client/oauth/flow_test.rb | 69 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index acc0f1c..f99b6f1 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -87,7 +87,7 @@ module MCP ensure_pkce_supported!(as_metadata) - effective_scope = resolve_scope(scope: scope, prm: prm || {}) + effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) # Asked before registering, not after: a refusal must not leave this client registered at an authorization server @@ -898,7 +898,8 @@ module MCP def resolve_scope(scope:, prm:) return scope if scope && !scope.empty? - supported = prm["scopes_supported"] + # `prm` is nil on the legacy path, where nothing advertises scopes. + supported = prm && prm["scopes_supported"] return supported.join(" ") if supported.is_a?(Array) && !supported.empty? return @provider.scope if @provider.scope && !@provider.scope.empty? diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5daff12..a91006d 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -991,6 +991,75 @@ module MCP end end + def test_run_client_credentials_without_prm_uses_the_legacy_authorization_base + # A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes, + # so the provider's own scope is used and the tokens are bound to the origin. + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://srv.example.com", + token_endpoint: "https://srv.example.com/token", + token_endpoint_auth_methods_supported: ["client_secret_basic"], + ), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read") + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("legacy-token", provider.access_token) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "client_credentials" && + form["scope"] == "mcp:read" && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + received = nil + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + # The assertion is minted for the legacy authorization base, which is the only identity such a server has. + assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["resource"] == "https://srv.example.com/mcp" && + !form.key?("scope") + end + end + def test_run_legacy_fallback_rejects_insecure_authorization_base # The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not # become the authorization base URL. From f51ccfbe662f4f0320ceb5974d39eab317589e3d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 17 Sep 2026 02:39:45 +0900 Subject: [PATCH 244/247] Let providers without a CIMD URL reader refresh their tokens ## Motivation and Context `Flow#refresh!` reads `client_id_metadata_document_url` from the provider unconditionally, once to decide whether a provider without stored `client_information` may still refresh and once to build the CIMD `client_id`. Only `Provider` defines that reader: `CrossAppAccessProvider` and `ClientCredentialsProvider` raise `NoMethodError` once a stored `refresh_token` is found. `MCP::Client::HTTP` calls `refresh!` on any `401` while a `refresh_token` is stored, so a `jwt-bearer` client whose authorization server issued one fails with `NoMethodError` on the first expired token instead of refreshing. The error is not an `AuthorizationError`, so the transport does not fall back to the grant either. RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden, and one in storage must not crash the client. The flow now reads the URL through a duck-typed helper, like `authorization_flow` and `client_assertion`, so a provider without the reader has no CIMD URL and refreshes with its stored `client_information`: the `refresh_token` grant is sent with the stored client authentication, and no new ID-JAG assertion is obtained. The authorization-code registration path reads the URL through the same helper. The docs now say that a refresh token issued anyway is used on the next `401`, and that the assertion provider is not called again on refresh. A refresh token stored without the `issuer` the SDK records is refused for these two providers before any request: unlike `Provider`, they have no tokens that predate the record, and a refresh asks no validator, so such a token would be presented to whatever authorization server discovery names. The refusal is an `AuthorizationError`, which sends the transport back through the grant, and the grant asks. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` refresh through a `CrossAppAccessProvider` and a `ClientCredentialsProvider` holding a `refresh_token`, asserting the `refresh_token` grant, the Basic credentials, and that the assertion provider is not called. A transport test in `test/mcp/client/oauth/http_oauth_test.rb` sends a `401` to a `CrossAppAccessProvider` with a stored `refresh_token` and expects the refreshed bearer token on the retry. All three raise `NoMethodError` against the previous library. Further tests pin the issuer stamp on the refreshed tokens, the refusal of an authorization server that did not issue them for a `CrossAppAccessProvider`, a clean `AuthorizationError` for a provider with neither the reader nor stored `client_information`, and registration through DCR for a provider without the reader. A refresh token without a recorded issuer is refused for both providers before any request is sent. ## Breaking Changes None. --- docs/_client/authorization.md | 6 +- lib/mcp/client/oauth/flow.rb | 32 +++++- test/mcp/client/oauth/flow_test.rb | 132 +++++++++++++++++++++++ test/mcp/client/oauth/http_oauth_test.rb | 62 +++++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index b35b597..bb86458 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -40,6 +40,8 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). + `ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead; + their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again. - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. @@ -192,7 +194,8 @@ and redaction policy before persisting them or displaying them to users. For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at -the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3); +a refresh token the authorization server issues anyway is used on the next `401`. ```ruby provider = MCP::Client::OAuth::ClientCredentialsProvider.new( @@ -224,6 +227,7 @@ Keyword arguments: For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. +A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again. Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 1093eba..c34d617 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -182,7 +182,7 @@ module MCP # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, - # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). + # and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3). # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:) @@ -275,19 +275,22 @@ module MCP # checks before talking to it. # # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, + # when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer, # or when the token endpoint refuses the refresh request. # https://www.rfc-editor.org/rfc/rfc6749#section-6 def refresh!(server_url:, resource_metadata_url: nil) refresh_token = read_token("refresh_token") raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token + ensure_refresh_token_issuer_recorded! + stored_client_info = @provider.client_information have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id") # A CIMD-configured provider stores no `client_information` on purpose # (the CIMD URL is re-resolved against the live AS metadata on every flow). # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable. - have_cimd_url = !@provider.client_id_metadata_document_url.nil? + have_cimd_url = !provider_client_id_metadata_document_url.nil? unless have_stored_client_info || have_cimd_url raise AuthorizationError, "Cannot refresh: no client_information in provider storage." @@ -323,7 +326,7 @@ module MCP ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata) stored_client_info elsif as_metadata["client_id_metadata_document_supported"] == true - { "client_id" => @provider.client_id_metadata_document_url } + { "client_id" => provider_client_id_metadata_document_url } else raise AuthorizationError, "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ @@ -684,7 +687,7 @@ module MCP # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata # and the current `provider.client_id_metadata_document_url`. - cimd_url = @provider.client_id_metadata_document_url + cimd_url = provider_client_id_metadata_document_url if cimd_url && as_metadata["client_id_metadata_document_supported"] == true return { "client_id" => cimd_url } end @@ -814,6 +817,18 @@ module MCP MESSAGE end + # `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`). + # The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new, + # and a refresh asks no validator, so a token without an `issuer` would be presented to whatever + # authorization server discovery names now. Refusing it sends the transport back through the grant, + # which does ask. + def ensure_refresh_token_issuer_recorded! + return if provider_authorization_flow == :authorization_code + return unless read_token("issuer").nil? + + raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required." + end + def ensure_refreshable_client_information!(client_info, as_metadata:) stored_issuer = client_info_required_value(client_info, "issuer") return if stored_issuer.nil? @@ -1020,6 +1035,15 @@ module MCP params end + # The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader + # (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves + # every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`. + def provider_client_id_metadata_document_url + return unless @provider.respond_to?(:client_id_metadata_document_url) + + @provider.client_id_metadata_document_url + end + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index c463fdc..799ecd0 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -557,6 +557,138 @@ module MCP assert_not_requested(:post, "#{@auth_base}/token") end + def test_refresh_uses_the_stored_credentials_of_a_cross_app_access_provider + # RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden; + # when the authorization server issued one, it is exchanged with the stored client secret + # and no new ID-JAG assertion is minted. + assertion_calls = 0 + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { + assertion_calls += 1 + "id-jag-assertion" + }, + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_equal("test-token-from-flow", provider.access_token) + assert_equal(@auth_base, provider.tokens["issuer"]) + assert_equal(0, assertion_calls) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + !form.key?("assertion") && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("xaa-client:xaa-secret") + end + end + + def test_refresh_uses_the_stored_credentials_of_a_client_credentials_provider + # RFC 6749 Section 4.4.3 says a refresh token SHOULD NOT be issued for this grant, + # so one in storage is unusual, but it must be exchanged rather than crash the refresh. + provider = client_credentials_provider + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_equal(@auth_base, provider.tokens["issuer"]) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") + end + end + + def test_refresh_refuses_an_authorization_server_that_did_not_issue_a_cross_app_access_token + # The token-level issuer binding is what protects these providers: their stored `client_information` carries no `issuer`, + # so `ensure_refreshable_client_information!` has nothing to compare. + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => "https://old-as.example.com") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issued by a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_refuses_tokens_without_a_recorded_issuer_for_the_client_credentials_and_jwt_bearer_providers + # These providers had no refresh before their tokens recorded the issuer, so nothing older needs + # tolerating; without the record a refresh would go to whatever server discovery names, unasked. + cross_app = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + [cross_app, client_credentials_provider].each do |provider| + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError, "should refuse #{provider.class}") do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/record no issuer/, error.message) + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + assert_not_requested(:get, @prm_url) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_raises_cleanly_for_a_provider_with_neither_client_information_nor_a_cimd_url + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + provider.storage.save_client_information(nil) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/no client_information/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_registers_through_dcr_for_a_provider_without_a_cimd_url_reader + # Registration reads the CIMD URL through the same duck-typed helper as refresh, + # so a provider that lacks the reader goes to Dynamic Client Registration. + provider_class = Class.new(Provider) { undef_method :client_id_metadata_document_url } + state_value = nil + provider = provider_class.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:post, "#{@auth_base}/register") + end + def test_run_uses_authorization_code_grant_for_default_provider # A standard `Provider` declares `authorization_flow == :authorization_code`, # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 5b1be13..ce026c9 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -627,6 +627,68 @@ module MCP assert_equal("refreshed-token", provider.access_token) end + def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token + # Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader. + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"] != "Bearer refreshed-token" + }.to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer refreshed-token" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + token_endpoint: "#{@auth_base}/token", + grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + ), + ) + + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt") + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600), + ) + + assertion_calls = 0 + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { + assertion_calls += 1 + "id-jag-assertion" + }, + ) + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("refreshed-token", provider.access_token) + assert_equal(0, assertion_calls) + end + def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing # A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized: # the refresh attempt must raise rather than fall through to the interactive flow, From c9f381a3ee03768952099f1daf3fff9bc2dba5da Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 18 Sep 2026 01:15:37 +0900 Subject: [PATCH 245/247] Let a provider customize the HTTP client the OAuth flow uses ## Motivation and Context `MCP::Client::OAuth::Flow` takes an `http_client_factory:`, but `MCP::Client::HTTP` builds the flow itself and passes none, so an application using `oauth:` could not add middleware to the requests the flow makes: Protected Resource Metadata and authorization server metadata discovery, dynamic client registration, and every token request. The block that customizes the transport's Faraday connection does not help, because that connection is bound to the MCP server URL and carries the `headers:` meant for that server, and the authorization server is a different origin. The TypeScript SDK hands the transport's `fetch` to `auth()` and the Python SDK sends the provider's requests through the transport's httpx client, so both let an application instrument these requests; this SDK did not. (#558) Providers now take `http_client_customizer:`, a callable invoked with the Faraday connection the flow builds for its own requests, next to the other callables a provider already holds. It runs after the SDK's defaults (`Accept: application/json`, no redirect following) and between two middlewares of the SDK's own: `Flow::RequestedURLStamp`, registered before the customizer is invoked, records the URL the flow asked for on the request environment, and `Flow::RequestedOriginGuard`, registered last, sees what the customizer's middleware did to the request after that record. A request that would leave that origin, by following a redirect or rewriting the URL, or that arrives without the record, is refused with `Flow::DestinationMismatchError` before it reaches the adapter. The record lives on the environment rather than in `env.request.context`, which stays the application's to fill or replace. The error is an `ArgumentError` rather than an `AuthorizationError` so neither discovery nor the transport's refresh fallback can absorb a configuration mistake. The boundary resembles the one the Python SDK keeps for its own requests, which follows a redirect only within the origin; this flow follows none itself. `Flow.build_http_client` assembles that connection and `Flow.new(http_client_factory:)` keeps replacing it whole. Following done inside an adapter cannot be seen by any guard, so the documentation keeps that, `Accept-Encoding`, and `raise_error` off the list of things a customizer may add, and notes that a recording middleware sees client credentials and tokens. Fixes #558. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`, `test/mcp/client/oauth/http_oauth_test.rb`, and the three provider test files. Against the library before this change, every one of them fails, on the unknown keyword or on the missing reader. ## Breaking Changes None. The customizer is opt-in, and the guard cannot fire on the SDK's own middleware-free connection. --- docs/_client/authorization.md | 45 +- docs/_client/transports.md | 3 + lib/mcp/client/oauth/bounded_body.rb | 2 +- .../oauth/client_credentials_provider.rb | 8 +- .../client/oauth/cross_app_access_provider.rb | 8 +- lib/mcp/client/oauth/flow.rb | 116 +++++- lib/mcp/client/oauth/id_jag_token_exchange.rb | 2 +- lib/mcp/client/oauth/provider.rb | 8 +- .../client/oauth/storage_backed_provider.rb | 15 + .../oauth/client_credentials_provider_test.rb | 19 + .../oauth/cross_app_access_provider_test.rb | 30 ++ test/mcp/client/oauth/flow_test.rb | 390 +++++++++++++++++- test/mcp/client/oauth/http_oauth_test.rb | 216 +++++++++- test/mcp/client/oauth/provider_test.rb | 20 + 14 files changed, 856 insertions(+), 26 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index b35b597..314d712 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -95,6 +95,8 @@ Optional keyword arguments: - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. - `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built. Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request). +- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard. + See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client). - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically @@ -216,7 +218,7 @@ Keyword arguments: - `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` or `"RS256"`; `client_secret` must not be set, because the private key is the credential. -- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`. ### Cross-App Access (JWT Bearer) Grant @@ -254,7 +256,39 @@ Keyword arguments: - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). Passing both through to `IDJAGTokenExchange.request` covers the common case. -- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. + +### Customizing the OAuth HTTP Client + +The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery, +dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own, +not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server. +To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider: + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder }, +) +``` + +The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL, +and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection. +It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently; +today it runs once per authorization attempt, but that is not a promise. +A few constraints follow from the checks described below: + +- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send + to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError` + before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)` + that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off. +- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off. +- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request + and to turn a token endpoint error into `Flow::InvalidGrantError`. +- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives. +- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens, + and the access tokens in token responses; redact them before they reach a log. ### Communication Security @@ -286,8 +320,11 @@ The range check compares IP literals and does not resolve hostnames, so it canno such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly. -If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against -the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. +On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request +goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)` +replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against +the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused. +A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself. The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. diff --git a/docs/_client/transports.md b/docs/_client/transports.md index 4362666..d69e5e9 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -248,6 +248,9 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f end ``` +The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through +the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client). + {: .note } > Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside > the SSE streaming callback of another response, re-entering the connection on the same thread. diff --git a/lib/mcp/client/oauth/bounded_body.rb b/lib/mcp/client/oauth/bounded_body.rb index 4aab2ad..f3b5efa 100644 --- a/lib/mcp/client/oauth/bounded_body.rb +++ b/lib/mcp/client/oauth/bounded_body.rb @@ -26,7 +26,7 @@ module MCP # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body # that expands past the cap is refused partway through the expansion rather than after it. That holds only while - # the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`. + # the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`. def on_data proc do |chunk, _received_bytes, _env| @buffer << chunk diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index 22ef56a..4e1e1cf 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -46,6 +46,8 @@ module MCP # server requires beyond the grant itself (Auth0's `audience`, for example). # A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`. # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for + # its own requests; see `StorageBackedProvider#http_client_customizer`. class ClientCredentialsProvider include StorageBackedProvider @@ -66,7 +68,8 @@ module MCP scope: nil, storage: nil, authorization_request_validator: nil, - token_request_params: nil + token_request_params: nil, + http_client_customizer: nil ) if blank?(client_id) raise InvalidCredentialsError, "client_id is required for the client_credentials grant." @@ -104,6 +107,8 @@ module MCP client_information["client_secret"] = client_secret end + http_client_customizer = validated_http_client_customizer(http_client_customizer) + @client_id = client_id @private_key = private_key @signing_algorithm = signing_algorithm @@ -111,6 +116,7 @@ module MCP @storage = storage || InMemoryStorage.new @authorization_request_validator = authorization_request_validator @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer @storage.save_client_information(client_information) end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb index 422cbb5..44b5ffd 100644 --- a/lib/mcp/client/oauth/cross_app_access_provider.rb +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -29,6 +29,8 @@ module MCP # the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises # `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen. # See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests; + # see `StorageBackedProvider#http_client_customizer`. # # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 class CrossAppAccessProvider @@ -46,7 +48,8 @@ module MCP scope: nil, storage: nil, authorization_request_validator: nil, - token_request_params: nil + token_request_params: nil, + http_client_customizer: nil ) if blank?(client_id) raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." @@ -60,11 +63,14 @@ module MCP raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." end + http_client_customizer = validated_http_client_customizer(http_client_customizer) + @assertion_provider = assertion_provider @scope = scope @storage = storage || InMemoryStorage.new @authorization_request_validator = authorization_request_validator @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer @storage.save_client_information( "client_id" => client_id, "client_secret" => client_secret, diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 1093eba..5183c6b 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -63,6 +63,71 @@ module MCP # a failed refresh as a reason to run the interactive flow. class InvalidTokenRequestParamsError < ArgumentError; end + # Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer` + # would send a request to an origin other than the one the flow validated, or has dropped the record of + # the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake, + # and deliberately outside `AuthorizationError`, which discovery treats as "nothing published" + # and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow. + class DestinationMismatchError < ArgumentError; end + + # Faraday middleware registered on the connection `build_http_client` assembles before the customizer + # is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly + # as the flow requested it, which it records on the request environment for `RequestedOriginGuard`. + # The guard covers what happens to a request after that record; middleware inserted ahead of it with + # `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard. + # The record lives on the environment, not in `env.request.context`: that slot belongs to the application, + # which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen + # on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after + # a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with + # the redirected URL. + class RequestedURLStamp + KEY = :mcp_oauth_requested_url + + def initialize(app) + @app = app + end + + def call(env) + env[KEY] ||= env.url.to_s + @app.call(env) + end + end + + # Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added + # middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked + # for is refused before it reaches the adapter, since every destination check ran against the URL as + # written; a same-origin change stays with the server those checks admitted. The record survives + # the `env.dup` that redirect-following middleware performs, and a request that arrives without it is + # refused as well, so a middleware that rebuilds the environment fails closed rather than open. + # The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows + # a redirect itself only within the origin; this flow follows none. + class RequestedOriginGuard + def initialize(app) + @app = app + end + + def call(env) + requested = env[RequestedURLStamp::KEY] + unless requested + raise DestinationMismatchError, <<~MESSAGE + Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \ + the URL the flow asked for; middleware that rebuilds the request environment is refused. + MESSAGE + end + + unless Discovery.same_origin?(env.url.to_s, requested) + raise DestinationMismatchError, <<~MESSAGE + Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \ + #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \ + middleware that follows redirects or rewrites URLs is refused. + MESSAGE + end + + @app.call(env) + end + end + private_constant :RequestedURLStamp, :RequestedOriginGuard + class << self # Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can. # Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`, @@ -82,6 +147,33 @@ module MCP nil end + + # Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`, + # then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`), + # then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request + # after the stamp recorded it. Every request on the connection passes through both, so a caller using + # it directly is held to the same origin rule. + # + # Deliberately built without redirect-following middleware. Every destination check in this class runs + # against the URL as written, before the request goes out, so a connection that transparently followed + # a `3xx` would let a server reach a host the checks just refused. The guard turns following at + # the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must + # not enable it. + # + # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes + # the response only while the caller has not claimed that header; assigning it turns `decode_content` off, + # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it + # after the check. + def build_http_client(customizer = nil) + require "faraday" + + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + faraday.use(RequestedURLStamp) + customizer&.call(faraday) + faraday.use(RequestedOriginGuard) + end + end end def initialize(provider:, http_client_factory: nil) @@ -1255,22 +1347,18 @@ module MCP @http_client ||= @http_client_factory.call end - # Deliberately built without redirect-following middleware. Every destination check in - # this class runs against the URL as written, before the request goes out, so a connection - # that transparently followed a `3xx` would let a server reach a host the checks just refused. - # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here - # and the guards above only cover the first hop. - # - # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes - # the response only while the caller has not claimed that header; assigning it turns `decode_content` off, - # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it - # after the check. + # A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and + # `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on + # `build_http_client`; `bounded_request` caps its responses all the same. def default_http_client - require "faraday" + self.class.build_http_client(provider_http_client_customizer) + end - Faraday.new do |faraday| - faraday.headers["Accept"] = "application/json" - end + # `nil` for a provider that predates the hook or leaves it unset. + def provider_http_client_customizer + return unless @provider.respond_to?(:http_client_customizer) + + @provider.http_client_customizer end def response_body_string(response) diff --git a/lib/mcp/client/oauth/id_jag_token_exchange.rb b/lib/mcp/client/oauth/id_jag_token_exchange.rb index 57701ba..ea771a9 100644 --- a/lib/mcp/client/oauth/id_jag_token_exchange.rb +++ b/lib/mcp/client/oauth/id_jag_token_exchange.rb @@ -95,7 +95,7 @@ module MCP assertion end - # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`: + # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`: # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap # onto compressed bytes. def default_http_client diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 16f8c4e..803b5b9 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -53,6 +53,8 @@ module MCP # the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` # raises `Flow::InvalidTokenRequestParamsError`. # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for + # its own requests; see `StorageBackedProvider#http_client_customizer`. class Provider include StorageBackedProvider @@ -93,7 +95,8 @@ module MCP storage: nil, client_id_metadata_document_url: nil, authorization_request_validator: nil, - token_request_params: nil + token_request_params: nil, + http_client_customizer: nil ) unless Discovery.secure_url?(redirect_uri) raise InsecureRedirectURIError, @@ -115,6 +118,8 @@ module MCP "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." end + http_client_customizer = validated_http_client_customizer(http_client_customizer) + @client_metadata = client_metadata @redirect_uri = redirect_uri @redirect_handler = redirect_handler @@ -124,6 +129,7 @@ module MCP @client_id_metadata_document_url = client_id_metadata_document_url @authorization_request_validator = authorization_request_validator @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer end # Identifies the OAuth flow this provider drives. diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb index 2be8acc..087f223 100644 --- a/lib/mcp/client/oauth/storage_backed_provider.rb +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -28,6 +28,13 @@ module MCP # with `Flow::InvalidTokenRequestParamsError`. attr_reader :token_request_params + # Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests, + # after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can + # add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default) + # keeps the default connection. The transport's own connection and customizer block never serve these requests: + # they are bound to the MCP server URL and carry headers meant for that server. + attr_reader :http_client_customizer + def access_token tokens&.dig("access_token") || tokens&.dig(:access_token) end @@ -66,6 +73,14 @@ module MCP params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze end + + # Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed + # in place of a callable fails at construction rather than at the first `401`. + def validated_http_client_customizer(customizer) + return customizer if customizer.nil? || customizer.respond_to?(:call) + + raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})." + end end end end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index 5ff4d20..6c679e3 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -49,6 +49,25 @@ module MCP assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"]) end + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", http_client_customizer: customizer) + + assert_same(customizer, provider.http_client_customizer) + assert_nil(ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret").http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials + storage = InMemoryStorage.new + + error = assert_raises(ArgumentError) do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", storage: storage, http_client_customizer: "recorder") + end + + assert_equal("http_client_customizer must respond to call (got String).", error.message) + assert_nil(storage.client_information) + end + def test_initialize_rejects_missing_client_id ["", " ", nil].each do |value| assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb index 1283b62..ed49b47 100644 --- a/test/mcp/client/oauth/cross_app_access_provider_test.rb +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -49,6 +49,36 @@ module MCP ) end + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + http_client_customizer: customizer, + ) + + assert_same(customizer, provider.http_client_customizer) + assert_nil(build_provider.http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials + storage = InMemoryStorage.new + + error = assert_raises(ArgumentError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + storage: storage, + http_client_customizer: "recorder", + ) + end + + assert_equal("http_client_customizer must respond to call (got String).", error.message) + assert_nil(storage.client_information) + end + def test_initialize_rejects_missing_client_id assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do CrossAppAccessProvider.new( diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index c463fdc..1d2a670 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -85,12 +85,13 @@ module MCP ) end - def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil) + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil, http_client_customizer: nil) ClientCredentialsProvider.new( client_id: "cc-client", client_secret: "cc-secret", token_endpoint_auth_method: token_endpoint_auth_method, token_request_params: token_request_params, + http_client_customizer: http_client_customizer, ) end @@ -141,6 +142,118 @@ module MCP end end + # Records `[method, url]` for every request that passes through, standing in for the tracing middleware + # an application adds through `http_client_customizer`. + class RecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << [env.method, env.url.to_s] + @app.call(env) + end + end + + # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind: + # the flow asked for one URL and the adapter is handed another. + class URLRewritingMiddleware + def initialize(app, from:, to:) + @app = app + @from = from + @to = to + end + + def call(env) + env.url = URI(@to) if env.url.to_s == @from + @app.call(env) + end + end + + # Replaces the per-request context the way instrumentation middleware may; the guard must not depend on it. + class ContextReplacingMiddleware + def initialize(app) + @app = app + end + + def call(env) + env.request.context = { tag: "instrumented" } + @app.call(env) + end + end + + # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever + # an earlier middleware recorded on the original. + class EnvRebuildingMiddleware + def initialize(app) + @app = app + end + + def call(env) + @app.call(Faraday::Env.from(env.to_h)) + end + end + + # Follows a `3xx` the way `faraday-follow_redirects` does: duplicates the environment, points it at the `Location`, + # and re-enters the stack from its own position. + class RedirectFollowingMiddleware + def initialize(app) + @app = app + end + + def call(env) + response = @app.call(env) + location = response.headers["Location"] + return response unless location && (300..399).cover?(response.status) + + redirected = env.dup + redirected.url = URI(location) + redirected.response = nil + @app.call(redirected) + end + end + + # Replaces the request options wholesale, the way middleware that resets timeouts or contexts might. + class RequestOptionsReplacingMiddleware + def initialize(app) + @app = app + end + + def call(env) + env.request = Faraday::RequestOptions.new + @app.call(env) + end + end + + # Re-enters the stack once with the same environment after a `5xx`, the way retry middleware does. + class RetryingMiddleware + def initialize(app) + @app = app + end + + def call(env) + response = @app.call(env) + return response unless response.status >= 500 + + env.response = nil + @app.call(env) + end + end + + # Records the application's `trace_id` from the per-request context, as a tracing middleware would. + class ContextRecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << env.request.context&.dig(:trace_id) + @app.call(env) + end + end + # Runs the full authorization flow and returns the `scope` query parameter # sent on the authorization request. The caller stubs the AS metadata; # this helper supplies a provider whose `grant_types` and optional pre-set @@ -190,6 +303,278 @@ module MCP end end + def test_run_sends_every_authorization_server_request_through_the_provider_customizer + log = [] + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal([[:get, @prm_url], [:get, @as_metadata_url], [:post, "#{@auth_base}/token"]], log) + + # The SDK's defaults are applied before the customizer runs. + assert_requested(:get, @prm_url, headers: { "Accept" => "application/json" }) + end + + def test_run_refuses_a_customized_connection_that_sends_a_request_off_the_origin + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_equal(<<~MESSAGE, error.message) + Request to \"#{@prm_url}\" would be sent to \"https://other.example.com/prm.json\", on a different origin; \ + middleware that follows redirects or rewrites URLs is refused. + MESSAGE + + # The guard sits before the adapter, so the request never leaves. + assert_not_requested(:get, "https://other.example.com/prm.json") + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_keeps_a_same_origin_rewrite + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: moved_url) }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_context + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(ContextReplacingMiddleware) + faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") + }, + ) + + assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_ahead_of_the_stamp + # `builder.insert(0, ...)` is where middleware that wants to be outermost puts itself, so a follower can + # land ahead of the SDK's stamp and re-enter the stack; the record of the first URL must survive that. + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_ahead_of_the_stamp + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_refuses_a_request_whose_environment_was_rebuilt + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_equal(<<~MESSAGE, error.message) + Request to \"#{@prm_url}\" carries no record of the URL the flow asked for; \ + middleware that rebuilds the request environment is refused. + MESSAGE + assert_not_requested(:get, @prm_url) + end + + def test_run_refuses_a_rewritten_token_request + stub_request(:post, "https://other.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "stolen", token_type: "Bearer"), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") + }, + ) + + assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://other.example.com/token") + assert_not_requested(:post, "#{@auth_base}/token") + assert_nil(provider.access_token) + end + + def test_run_keeps_the_application_request_context_for_every_request + trace_ids = [] + + result = run_authorization_flow( + http_client_customizer: ->(faraday) { + faraday.options.context = { trace_id: "incident-123" } + faraday.use(ContextRecordingMiddleware, trace_ids) + }, + ) + + assert_equal(:authorized, result) + # Protected Resource Metadata and authorization server metadata (GET), registration (JSON POST), + # and the token exchange (form POST) all carry it. + assert_equal(["incident-123"] * 4, trace_ids) + end + + def test_run_keeps_the_application_request_context_on_a_factory_connection + trace_ids = [] + factory = -> { + Faraday.new do |faraday| + faraday.options.context = { trace_id: "incident-123" } + faraday.use(ContextRecordingMiddleware, trace_ids) + end + } + + result = Flow.new(provider: client_credentials_provider, http_client_factory: factory).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal(["incident-123"] * 3, trace_ids) + end + + def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_options + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(RequestOptionsReplacingMiddleware) + faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") + }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # The mismatch, not a lost record: replacing the options must leave the record in place. + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_after_the_stamp + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # The mismatch, not a lost record: the duplicate the follower re-enters with shares the record. + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_after_the_stamp + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_keeps_a_retry_by_middleware_placed_ahead_of_the_stamp + # The same environment re-enters the stack with the same URL, so the first record still matches. + stub_request(:get, @prm_url).to_return( + { status: 503 }, + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + }, + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.builder.insert(0, RetryingMiddleware) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, @prm_url, times: 2) + end + + def test_build_http_client_records_each_request_on_its_own + # A caller using the SDK-built connection directly may address different origins from one request + # to the next; each request gets its own record. + stub_request(:get, "https://a.example.com/one").to_return(status: 200, body: "one") + stub_request(:get, "https://b.example.com/two").to_return(status: 200, body: "two") + connection = Flow.build_http_client + + assert_equal("one", connection.get("https://a.example.com/one").body) + assert_equal("two", connection.get("https://b.example.com/two").body) + end + + def test_run_prefers_an_explicit_http_client_factory_over_the_customizer + provider = client_credentials_provider(http_client_customizer: ->(_faraday) { raise "the customizer must not run" }) + + result = Flow.new(provider: provider, http_client_factory: -> { Faraday.new }).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body # `client_secret_post` puts the credentials in the form body rather # than an HTTP Basic header (RFC 6749 Section 2.3.1). @@ -586,7 +971,7 @@ module MCP # the Dynamic Client Registration request body. The default loopback redirect URI # exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises # the `"web"` inference. - def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}) + def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}, http_client_customizer: nil) state_holder = {} provider = Provider.new( client_metadata: { @@ -598,6 +983,7 @@ module MCP redirect_uri: redirect_uri, redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + http_client_customizer: http_client_customizer, ) Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 5b1be13..1edab1e 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -11,6 +11,46 @@ module MCP class Client module OAuth class HTTPOAuthTest < Minitest::Test + # Records `[method, url]` for every request that passes through, standing in for the tracing + # middleware an application adds through `http_client_customizer`. + class RecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << [env.method, env.url.to_s] + @app.call(env) + end + end + + # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind. + class URLRewritingMiddleware + def initialize(app, from:, to:) + @app = app + @from = from + @to = to + end + + def call(env) + env.url = URI(@to) if env.url.to_s == @from + @app.call(env) + end + end + + # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever + # an earlier middleware recorded on the original. + class EnvRebuildingMiddleware + def initialize(app) + @app = app + end + + def call(env) + @app.call(Faraday::Env.from(env.to_h)) + end + end + def setup WebMock.enable! @mcp_url = "https://srv.example.com/mcp" @@ -101,6 +141,91 @@ module MCP assert_equal("test-token-after-flow", provider.access_token) end + def test_send_request_runs_the_oauth_flow_through_the_provider_customizer + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"].nil? + }.to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer test-token-after-flow" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600), + ) + + log = [] + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal( + [ + [:get, @prm_url], + [:get, "#{@auth_base}/.well-known/oauth-authorization-server"], + [:post, "#{@auth_base}/register"], + [:post, "#{@auth_base}/token"], + ], + log, + ) + end + def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin # End to end over the transport, which is where the header is actually parsed: # a server that answers 401 must not be able to name an unrelated host in @@ -678,6 +803,94 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end + def test_send_request_surfaces_a_refused_refresh_without_falling_back + # A customizer middleware that would carry the refresh request off the origin is refused before + # the request leaves, and that refusal is not one of the failures that start the interactive flow + # or discard the stored tokens. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:post, "https://other.example.com/token").to_raise(StandardError.new("the rewritten request must not leave.")) + + provider = build_provider( + http_client_customizer: ->(faraday) { + faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") + }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_not_requested(:post, "https://other.example.com/token") + assert_not_requested(:post, "#{@auth_base}/token") + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_send_request_surfaces_a_refresh_whose_environment_was_rebuilt_without_falling_back + # The missing-record refusal takes the same route as the origin mismatch: out of `send_request`, + # with no interactive fallback and the stored tokens intact. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + stub_request(:get, @prm_url).to_raise(StandardError.new("the rebuilt request must not leave.")) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + interactive_flow_started = false + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { interactive_flow_started = true }, + callback_handler: -> { ["code", "state"] }, + http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_match(/carries no record of the URL the flow asked for/, error.message) + refute(interactive_flow_started, "the refusal must not fall back to the interactive flow") + assert_not_requested(:get, @prm_url) + assert_not_requested(:post, "#{@auth_base}/token") + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal({ "access_token" => "stale-token", "refresh_token" => "saved-rt" }, provider.tokens) + end + def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure # A 5xx (or any non-`invalid_grant`) from the token endpoint indicates # a transient AS outage, NOT that the refresh token is dead. @@ -1656,12 +1869,13 @@ module MCP provider end - def build_provider + def build_provider(http_client_customizer: nil) Provider.new( client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, redirect_uri: "http://localhost:0/callback", redirect_handler: ->(_url) {}, callback_handler: -> { ["code", "state"] }, + http_client_customizer: http_client_customizer, ) end end diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index d9fa64e..7425131 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -38,6 +38,26 @@ module MCP end end + def test_initialize_defaults_http_client_customizer_to_nil + assert_nil(Provider.new(**args_for("https://app.example.com/callback")).http_client_customizer) + end + + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: customizer) + + assert_same(customizer, provider.http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer + # A connection object in place of a callable is the likely mistake. + error = assert_raises(ArgumentError) do + Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: Object.new) + end + + assert_equal("http_client_customizer must respond to call (got Object).", error.message) + end + def test_initialize_rejects_non_loopback_http_redirect_uri # Communication Security: a non-loopback `http://` redirect URI would # let an attacker steal the authorization code from a network sniffer, From 5081cc562618c518ddf587f2dfa72447175c075b Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 18 Sep 2026 03:49:09 +0900 Subject: [PATCH 246/247] Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery ## Motivation and Context `Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none" and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL. A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes, so a transient failure moved the flow to a different authorization server, discarded client information and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin, rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so: outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them and refuses the legacy path after a `5xx` or `429` from any candidate. Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted. Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path; moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError` (a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path; `MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead. Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered. A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path. The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length, but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are. The default legacy endpoints for an origin whose own metadata document is absent are unchanged. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library before this change, discovery in the scenarios these tests set up took the legacy path instead of raising. ## Breaking Changes When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server, or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin, and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback. --- docs/_client/authorization.md | 2 + lib/mcp/client/oauth/discovery.rb | 23 +++ lib/mcp/client/oauth/flow.rb | 111 ++++++++---- test/mcp/client/oauth/discovery_test.rb | 13 ++ test/mcp/client/oauth/flow_test.rb | 226 +++++++++++++++++++++++- 5 files changed, 332 insertions(+), 43 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index ebd3014..11e9215 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -39,6 +39,8 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3), and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. + When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`, + raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index 04030cc..a8ad3be 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -336,6 +336,29 @@ module MCP uri.to_s end + # Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting + # a URL next to the request that failed, where it must still match the server's access log; `URI#to_s` + # still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path` + # it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters + # for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry + # the very credentials being dropped. + def redact_url(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + uri.query = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + + uri.to_s + rescue URI::Error + "[unparseable URL]" + end + # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL): # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM # also advertises a query string, the server's query MUST be identical to it (otherwise diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 0d38f2d..37c0cce 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -16,6 +16,8 @@ module MCP class Flow TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512 + METADATA_DIAGNOSTIC_MAX_LENGTH = 128 + METADATA_URL_MAX_LENGTH = 2048 # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`, # so a provider naming one of these is refused rather than left believing its value was sent. @@ -57,6 +59,17 @@ module MCP # or authorization server metadata failure by rescuing a class rather than by matching the message text. class AuthorizationRefusedError < AuthorizationError; end + # Raised by metadata discovery when every candidate URL answered that nothing usable is published there + # (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object). + # The only discovery failure that may select the legacy 2025-03-26 path. + class MetadataNotPublishedError < AuthorizationError; end + + # Raised by metadata discovery when the answer says nothing about what is published: the request failed to + # reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move + # the flow to a different authorization server because of a transient failure, so it is surfaced instead, + # as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both. + class MetadataUnreachableError < AuthorizationError; end + # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity, # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake, # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on @@ -375,7 +388,12 @@ module MCP # # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs. - # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior. + # Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`, + # a redirect that was not followed, or a body that is not a JSON object) selects the legacy path. + # A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes, + # so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`), + # as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors. + # A body over the response cap is refused outright and never reaches the fallback either. # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery def locate_authorization_server(server_url:, resource_metadata_url:) prm = begin @@ -383,7 +401,7 @@ module MCP server_url: server_url, resource_metadata_url: resource_metadata_url, ) - rescue AuthorizationError + rescue MetadataNotPublishedError nil end @@ -504,44 +522,63 @@ module MCP first end - # Walks candidate metadata URLs and returns the parsed JSON body of - # the first 2xx response. Raises `AuthorizationError` for transport - # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`) - # so callers do not have to handle raw Faraday/JSON exceptions. + # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object; + # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still + # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path, + # or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing + # about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises + # `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that + # was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`. + # A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`, + # before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment + # and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against + # a server's access log, and with exception text bounded, since the message lands in every log destination + # the error passes through. def fetch_metadata_json(urls, label:) - last_error = nil + failures = [] + inconclusive = false urls.each do |url| response = begin http_get(url) rescue Faraday::Error => e - last_error = "GET #{url} raised #{e.class}: #{e.message}" + detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) + failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}" + inconclusive = true next end - if response.status >= 200 && response.status < 300 - parsed = begin - JSON.parse(response_body_string(response)) - rescue JSON::ParserError => e - raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}." - end - - # Even valid JSON can be the wrong shape (a top-level array, - # a bare `null`, a string, ...). The discovery callers index by - # name (`prm["authorization_servers"]`, etc.), so anything that - # is not a Hash would raise `TypeError` / `NoMethodError` - # downstream. Surface that as `AuthorizationError` instead so - # callers see a single, documented error type. - unless parsed.is_a?(Hash) - raise AuthorizationError, - "#{label} from #{url} is not a JSON object (got #{parsed.class})." - end - - return parsed + unless response.status >= 200 && response.status < 300 + failures << "GET #{reported_url(url)} returned #{response.status}" + inconclusive = true if response.status >= 500 || response.status == 429 + next end - last_error = "GET #{url} returned #{response.status}" + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name + failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}" + next + end + + # Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...). + # The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that + # is not a Hash would raise `TypeError` / `NoMethodError` downstream. + unless parsed.is_a?(Hash) + failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})" + next + end + + return parsed + end + + message = "Failed to fetch #{label}: #{failures.join("; ")}." + + if inconclusive + raise MetadataUnreachableError, message + else + raise MetadataNotPublishedError, message end - raise AuthorizationError, "Failed to fetch #{label}: #{last_error}." end def ensure_pkce_supported!(as_metadata) @@ -1188,8 +1225,8 @@ module MCP parsed = {} unless parsed.is_a?(Hash) error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError - error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) - description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) + error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) + description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) message += " #{[error, description].compact.join(": ")}" if error || description error_class.new(message, http_status: response.status, error: error, error_description: description) @@ -1198,17 +1235,23 @@ module MCP error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status) end - def token_endpoint_diagnostic(value, limit:) + def bounded_diagnostic(value, limit:) return unless value.is_a?(String) - # RFC 6749 permits printable ASCII except double quotes and backslashes. - # Replace other characters to keep provider text on one log line. + # RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields. + # Replace other characters to keep text received off the network on one log line. value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip return if value.empty? value.length > limit ? "#{value[0, limit - 3]}..." : value end + # A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot + # grow the message without limit. + def reported_url(url) + bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH) + end + # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be # `application/x-www-form-urlencoded` encoded before they are joined with # `:` and base64-encoded for the `Authorization: Basic` header. This is diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index c8d9fda..d58c7f2 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -259,6 +259,19 @@ module MCP ) end + def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling + # Reported next to a failed request, the URL must still match the server's access log, so the host + # and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding. + assert_equal( + "https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson", + Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"), + ) + end + + def test_redact_url_does_not_echo_a_url_it_cannot_parse + assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc")) + end + def test_canonicalize_url_normalizes_query_to_match_faraday # Faraday rewrites `env.url` before sending a request: it sorts # parameters by name, uppercases percent-encoded hex, and drops diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 26adb60..5a41b3b 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -1084,9 +1084,9 @@ module MCP end def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object - # Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path - # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing - # a domain error rather than a raw `TypeError` from indexing the array. + # Valid JSON but the wrong shape counts as nothing usable being published, so it selects the legacy 2025-03-26 path + # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing a domain error rather than + # a raw `TypeError` from indexing the array. stub_request(:get, @prm_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -1118,6 +1118,211 @@ module MCP assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") end + def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back + # A request that never reached the server says nothing about whether it publishes PRM, so once + # the other candidates answer 404 the flow stops instead of moving to the legacy authorization base; + # the TypeScript and Python SDKs propagate the error the same way. + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_match(/Faraday::ConnectionFailed/, error.message) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/register") + end + + def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error + # The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line + # the error reaches, so the query is dropped, while the path keeps the spelling that was requested + # (`%2E` is not decoded) so the line can be matched against the server's access log. + challenge_url = "https://srv.example.com/prm%2Ejson?token=abc" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET") + refute_includes(error.message, "token=abc") + end + + def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error + # The challenge URL is the server's to choose, so its length is bounded before it lands in the message. + challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed") + refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3)) + end + + def test_run_bounds_the_transport_error_message_in_the_discovery_error + # A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats + # the status line and the request URL), so it is bounded like the parser's message. + stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000)) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET") + refute_includes(error.message, "x" * 126) + end + + def test_run_bounds_the_parser_message_in_the_discovery_error + # json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body + # could otherwise put up to the response cap into the message once per candidate. + stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "{") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 503) + JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'") + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots. + assert_includes(error.message, <<~MESSAGE.chomp) + returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \ + GET https://srv.example.com/.well-known/oauth-protected-resource returned 503. + MESSAGE + + refute_includes(error.message, "{" * 105) + end + + def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back + # A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404; + # the Python SDK refuses the legacy path the same way. + [503, 429].each do |status| + stub_request(:get, @prm_url).to_return(status: status) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned #{status}/, error.message) + end + + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/register") + end + + def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable + # An unusable document from a later candidate must not turn an earlier `503` into "nothing published". + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_return(status: 503) + stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_match(/returned 503/, error.message) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + end + + def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document + # The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped. + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") + + result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + + assert_equal(:authorized, result) + assert_requested(:get, @prm_url) + end + + def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error + # The OAuth document being unreachable must not stop the OpenID one from being tried. + stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired")) + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + result = run_authorization_flow + + assert_equal(:authorized, result) + assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration") + end + + def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document + # An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried. + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + ["{", "[]"].each do |body| + stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body) + + assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}") + end + + assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2) + end + + def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back + stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired")) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back + stub_request(:get, @prm_url).to_return(status: 503) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned 503/, error.message) + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + # Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert # which endpoint was used. def build_legacy_discovery_provider(holder) @@ -1511,10 +1716,10 @@ module MCP end def test_run_refuses_a_protected_resource_metadata_body_over_the_cap - # PRM discovery failures select the legacy path by design, so the refusal shows up as - # the fallback rather than as a raise. The padded document is valid JSON naming - # an authorization server, so contacting that server is exactly what would happen - # if the body had been read: the assertion below fails if the cap stops working. + # An oversized document is refused outright rather than treated as unpublished, so the flow stops + # without the legacy fallback. The padded document is valid JSON naming an authorization server, + # so contacting that server is exactly what would happen if the body had been read: + # the assertion below fails if the cap stops working. stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404) stub_request(:get, @prm_url).to_return( status: 200, @@ -1526,9 +1731,11 @@ module MCP ), ) - assert_raises(Flow::AuthorizationError) { run_authorization_flow } + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + assert_match(/exceeds \d+ bytes/, error.message) assert_not_requested(:get, @as_metadata_url) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") end def test_run_refuses_a_dynamic_client_registration_body_over_the_cap @@ -1632,6 +1839,7 @@ module MCP headers: { "Content-Type" => "application/json" }, body: "null", ) + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404) provider = Provider.new( client_metadata: { @@ -3283,7 +3491,7 @@ module MCP end def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises - Flow.any_instance.stubs(:token_endpoint_diagnostic).raises(ArgumentError, "sensitive provider text") + Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text") { "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass| error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details")) From 8173a38280b0c7c31b2ef5bc3e7dfd1f4ce7fa8b Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 19 Sep 2026 17:08:22 +0900 Subject: [PATCH 247/247] Validate the src and sizes of an icon against the specification ## Motivation and Context `MCP::Icon` validated `theme` but accepted anything for `src` and `sizes`: `src:` defaulted to `nil`, so `MCP::Icon.new.to_h` was `{}`, an icon with no source, and a `sizes` given as a String reached `to_h` unchanged and serialized as a JSON string where the specification's `Icon` type requires an array of strings. Nothing on the server side reported either; the server answered every request with 200 and the failure surfaced in a client, with no reference to the field responsible. The TypeScript SDK types `sizes` as `string[]` and `src` as required, and the Python SDK's model refuses such an icon at construction, so this SDK was the one that let it through. `src` is now a required keyword and must be a non-empty String, `sizes` must be `nil` or an Array of Strings, and `mime_type` must be `nil` or a String, each refused with `ArgumentError` at construction the way `theme` already was. The checks stop at the schema's types, as the reference SDKs do: the scheme of a URL and the `WxH` form of a size stay the caller's to get right. A message names the class of the rejected value, never the value, since `src` may carry a `data:` URI of any length. Coercing a String into a one-element Array was considered and rejected: it would hide the caller's mistake that the class exists to surface. The `theme` check let `false` through its truthiness test and serialized it; it now refuses everything but `nil`, `"light"`, and `"dark"`. Fixes #562. ## How Has This Been Tested? New tests in `test/mcp/icon_test.rb`. Against the previous library, an icon without `src` and an icon whose `sizes` is a String are both constructed and serialized. ## Breaking Changes `MCP::Icon.new` now raises `ArgumentError` where earlier releases produced an icon the specification's schema rejects: `src:` is required and must be a non-empty String, so a call without it, or with `nil`, an empty String, or a non-String, fails; `sizes:` must be `nil` or an Array of Strings, so a String, or an Array holding `nil` or a non-String, fails; `mime_type:` must be `nil` or a String; `theme:` must be `nil`, `"light"`, or `"dark"`, so `false` fails where it used to be serialized. Leaving the optional keywords out, or passing `nil` for them, is unchanged. --- lib/mcp/icon.rb | 37 ++++++++++++++++- test/mcp/icon_test.rb | 94 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb index 3397c33..56ac6ad 100644 --- a/lib/mcp/icon.rb +++ b/lib/mcp/icon.rb @@ -1,13 +1,35 @@ # frozen_string_literal: true module MCP + # An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type: + # https://modelcontextprotocol.io/specification/2026-07-28/basic#icons + # + # Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs, + # so an icon that would serialize into something a client rejects fails here instead. What a value means + # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String` + # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String` + # (the specification expects `WxH` or `"any"`). class Icon SUPPORTED_THEMES = ["light", "dark"].freeze attr_reader :mime_type, :sizes, :src, :theme - def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil) - raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme) + def initialize(mime_type: nil, sizes: nil, src:, theme: nil) + unless src.is_a?(String) && !src.empty? + raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})." + end + + unless mime_type.nil? || mime_type.is_a?(String) + raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})." + end + + if (problem = sizes_problem(sizes)) + raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})." + end + + unless theme.nil? || SUPPORTED_THEMES.include?(theme) + raise ArgumentError, 'The value of theme must specify "light" or "dark".' + end @mime_type = mime_type @sizes = sizes @@ -18,5 +40,16 @@ module MCP def to_h { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact end + + private + + def sizes_problem(sizes) + return if sizes.nil? + return "got #{sizes.class}" unless sizes.is_a?(Array) + + index = sizes.index { |size| !size.is_a?(String) } + + "got #{sizes[index].class} inside the Array" if index + end end end diff --git a/test/mcp/icon_test.rb b/test/mcp/icon_test.rb index e8c837c..f2fbc9a 100644 --- a/test/mcp/icon_test.rb +++ b/test/mcp/icon_test.rb @@ -15,32 +15,112 @@ module MCP assert_equal({ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }, icon.to_h) end - def test_initialization_by_default - icon = Icon.new + def test_initialization_with_only_src + icon = Icon.new(src: "https://example.com/icon.png") assert_nil(icon.mime_type) assert_nil(icon.sizes) - assert_nil(icon.src) + assert_equal("https://example.com/icon.png", icon.src) assert_nil(icon.theme) - assert_equal({}, icon.to_h) + assert_equal({ src: "https://example.com/icon.png" }, icon.to_h) + end + + def test_src_is_required + exception = assert_raises(ArgumentError) do + Icon.new + end + assert_equal("missing keyword: :src", exception.message) + end + + def test_src_rejects_nil_and_an_empty_string + [nil, ""].each do |src| + exception = assert_raises(ArgumentError) do + Icon.new(src: src) + end + assert_equal("The value of src must be a non-empty String (got #{src.class}).", exception.message) + end + end + + def test_src_rejects_a_non_string + exception = assert_raises(ArgumentError) do + Icon.new(src: :icon) + end + assert_equal("The value of src must be a non-empty String (got Symbol).", exception.message) + end + + def test_sizes_accepts_an_array_of_strings + [["48x48", "96x96"], ["any"], []].each do |sizes| + icon = Icon.new(sizes: sizes, src: "https://example.com/icon.png") + + assert_equal(sizes, icon.to_h[:sizes]) + end + end + + # https://github.com/modelcontextprotocol/ruby-sdk/issues/562 + def test_sizes_rejects_a_string + exception = assert_raises(ArgumentError) do + Icon.new(mime_type: "image/png", sizes: "51x51", src: "https://example.com/icon.png") + end + assert_equal( + 'The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', + exception.message, + ) + end + + def test_sizes_rejects_an_array_holding_a_non_string + { + ["48x48", 96] => "Integer", + [nil] => "NilClass", + ["48x48", nil] => "NilClass", + [nil, 96] => "NilClass", + }.each do |sizes, offender| + exception = assert_raises(ArgumentError) do + Icon.new(sizes: sizes, src: "https://example.com/icon.png") + end + assert_equal( + "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (got #{offender} inside the Array).", + exception.message, + ) + end + end + + def test_src_accepts_any_scheme_and_sizes_accept_any_string + string_subclass = Class.new(String) + icon = Icon.new(sizes: ["unconventional", +"48x48", string_subclass.new("any")], src: "custom:icon") + + assert_equal({ sizes: ["unconventional", "48x48", "any"], src: "custom:icon" }, icon.to_h) + end + + def test_mime_type_rejects_a_non_string + exception = assert_raises(ArgumentError) do + Icon.new(mime_type: :png, src: "https://example.com/icon.png") + end + assert_equal("The value of mime_type must be a String (got Symbol).", exception.message) end def test_valid_theme_for_light assert_nothing_raised do - Icon.new(theme: "light") + Icon.new(src: "https://example.com/icon.png", theme: "light") end end def test_valid_theme_for_dark assert_nothing_raised do - Icon.new(theme: "dark") + Icon.new(src: "https://example.com/icon.png", theme: "dark") end end def test_invalid_theme exception = assert_raises(ArgumentError) do - Icon.new(theme: "unexpected") + Icon.new(src: "https://example.com/icon.png", theme: "unexpected") + end + assert_equal('The value of theme must specify "light" or "dark".', exception.message) + end + + def test_theme_rejects_false + exception = assert_raises(ArgumentError) do + Icon.new(src: "https://example.com/icon.png", theme: false) end assert_equal('The value of theme must specify "light" or "dark".', exception.message) end