From f39b1bf4aa5e9b01414c530ce04cfec9260a9fe0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 03:03:47 +0900 Subject: [PATCH] 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 bdf9c25f..0a345490 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 @@ class RequestEnvelope # 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 @@ def parse!(params, request: nil) 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 @@ def read(meta, key) 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 47afb059..262352f6 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -689,10 +689,14 @@ def lift_request_envelope(params, method:, session:) 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 e4c25480..e7261be6 100644 --- a/test/mcp/request_envelope_test.rb +++ b/test/mcp/request_envelope_test.rb @@ -12,8 +12,9 @@ class RequestEnvelopeTest < ActiveSupport::TestCase 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 @@ class RequestEnvelopeTest < ActiveSupport::TestCase 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 @@ class RequestEnvelopeTest < ActiveSupport::TestCase 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 @@ class RequestEnvelopeTest < ActiveSupport::TestCase 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 1437877d..460ef16c 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -617,7 +617,7 @@ class StdioTransportTest < ActiveSupport::TestCase ]) 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 bf94fab9..cc43bad4 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 @@ def string )) 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 6dde54f5..69d9b8cb 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -295,7 +295,30 @@ class ServerTest < ActiveSupport::TestCase 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 @@ class ServerTest < ActiveSupport::TestCase 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 @@ class ServerTest < ActiveSupport::TestCase 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