From 9d4dcbf1a4ff272f27e1a27e6b23ce2c507f7ac4 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 1 Jun 2026 21:45:26 +0900 Subject: [PATCH 001/157] 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 002/157] [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 003/157] 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 004/157] [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 005/157] [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 006/157] 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 2a26ad2030ada924942190baf622e030127ab890 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 15:48:32 +0900 Subject: [PATCH 007/157] 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 a407cea086ddfc1785ca06d438eed79f83580ed3 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 28 May 2026 13:06:32 +0900 Subject: [PATCH 008/157] 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 009/157] 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 010/157] 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 011/157] 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 d096b8a7a93fc5f63be2557538742ad184fcc09e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:26:16 +0900 Subject: [PATCH 012/157] 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 013/157] 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 014/157] 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 015/157] 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 016/157] 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 017/157] 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 018/157] 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 019/157] 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 020/157] 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 021/157] 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 022/157] 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 023/157] 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 024/157] 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 025/157] 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 026/157] 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 027/157] 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 028/157] 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 029/157] 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 030/157] 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 031/157] 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 032/157] 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 033/157] 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 034/157] 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 035/157] 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 036/157] 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 037/157] 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 038/157] 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 039/157] 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 040/157] 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 041/157] [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 042/157] 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 043/157] 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 044/157] 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 045/157] 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 046/157] 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 047/157] 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 048/157] 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 049/157] 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 050/157] 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 051/157] 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 052/157] 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 053/157] 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 054/157] 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 055/157] 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 056/157] 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 057/157] 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 058/157] 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 059/157] 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 060/157] 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 061/157] 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 062/157] 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 063/157] 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 064/157] 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 065/157] 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 066/157] 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 067/157] 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 068/157] [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 069/157] 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 070/157] 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 071/157] 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 072/157] 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 073/157] 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 074/157] 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 075/157] 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 076/157] 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 077/157] 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 078/157] 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 079/157] 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 080/157] 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 081/157] 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 082/157] 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 083/157] 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 084/157] 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 085/157] 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 086/157] 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 087/157] [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 088/157] 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 089/157] 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 090/157] 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 091/157] 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 092/157] 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 093/157] 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 094/157] 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 095/157] 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 096/157] 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 097/157] 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 098/157] 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 099/157] 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 100/157] 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 101/157] 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 102/157] 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 103/157] 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 104/157] [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 105/157] 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 106/157] 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 107/157] 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 108/157] 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 109/157] 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 110/157] 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 111/157] 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 112/157] 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 113/157] 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 114/157] 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 115/157] [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 116/157] 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 117/157] 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 118/157] 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 119/157] 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 120/157] 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 121/157] 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 122/157] 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 123/157] [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 124/157] 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 125/157] 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 126/157] [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 127/157] [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 128/157] [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 129/157] 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 130/157] 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 131/157] 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 132/157] 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 133/157] 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 134/157] 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 135/157] 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 136/157] 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 137/157] [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 138/157] 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 139/157] 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 140/157] 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 141/157] [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 142/157] 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 143/157] 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 144/157] 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 145/157] 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 146/157] 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 147/157] [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 148/157] [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 149/157] 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 150/157] 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 151/157] 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 152/157] 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 153/157] 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 154/157] 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 155/157] 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 156/157] 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 157/157] 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