From d3aec1c3e60e0e118236d09d427694e561dfee97 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 25 Jul 2026 14:31:21 +0900 Subject: [PATCH] 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 5e0838ea..497919d2 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 3603e6fb..98f25e92 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -8,6 +8,20 @@ class Configuration ].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 09bb6fd3..603a7a9e 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 00000000..51834e8b --- /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 586d0def..0baa9738 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -59,6 +59,41 @@ def initialize(elicitations) 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 8270b101..732e5c25 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -66,6 +66,21 @@ class ConfigurationTest < ActiveSupport::TestCase 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 f00da6a6..80826b88 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 00000000..e4c25480 --- /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 7281a434..c51c2109 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -172,6 +172,49 @@ class ServerTest < ActiveSupport::TestCase 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",