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.
This commit is contained in:
parent
565f4de781
commit
d3aec1c3e6
9 changed files with 331 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
lib/mcp/request_envelope.rb
Normal file
95
lib/mcp/request_envelope.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
113
test/mcp/request_envelope_test.rb
Normal file
113
test/mcp/request_envelope_test.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue