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:
Koichi ITO 2026-07-25 14:31:21 +09:00
commit d3aec1c3e6
4 changed files with 331 additions and 8 deletions

View file

@ -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

View file

@ -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

View 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

View file

@ -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",