Skip to content

Commit 75fffab

Browse files
authored
Merge pull request modelcontextprotocol#471 from koic/stateless_foundations
Add Stateless Lifecycle Foundations per SEP-2575
2 parents 1ce7c97 + d3aec1c commit 75fffab

9 files changed

Lines changed: 331 additions & 8 deletions

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module MCP
1616
autoload :ErrorCodes, "mcp/error_codes"
1717
autoload :Icon, "mcp/icon"
1818
autoload :Prompt, "mcp/prompt"
19+
autoload :RequestEnvelope, "mcp/request_envelope"
1920
autoload :Resource, "mcp/resource"
2021
autoload :ResourceTemplate, "mcp/resource_template"
2122
autoload :ResultType, "mcp/result_type"

lib/mcp/configuration.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ class Configuration
88
].freeze
99
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
1010

11+
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575).
12+
# Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`: the modern lifecycle has
13+
# no `initialize` handshake, so these versions are never negotiated (each request carries its own version in `_meta`
14+
# and is validated against this list independently), and `protocol_version=` keeps rejecting them for the same reason.
15+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
16+
LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
17+
SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze
18+
19+
class << self
20+
def modern_protocol_version?(version)
21+
SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
22+
end
23+
end
24+
1125
attr_writer :exception_reporter, :around_request
1226

1327
# @deprecated Use {#around_request=} instead. `instrumentation_callback`

lib/mcp/error_codes.rb

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@
33
module MCP
44
# MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`.
55
#
6-
# Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
7-
# `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not
8-
# support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY`
9-
# rejects a request that requires a client capability the request did not declare
10-
# (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise
11-
# these codes itself yet.
6+
# All three constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
127
#
13-
# The values come from the spec's MCP-specific error code block, which is allocated sequentially from
14-
# `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here.
8+
# - `HEADER_MISMATCH` rejects an HTTP request whose headers do not match the corresponding body values,
9+
# or whose required headers are missing or malformed (no `error.data`). It is reserved for
10+
# the Streamable HTTP transport, since headers do not exist on stdio.
11+
# - `MISSING_REQUIRED_CLIENT_CAPABILITY` rejects a request that requires a client capability
12+
# the request did not declare (`error.data: { requiredCapabilities: {...} }`).
13+
# Raised via `Server::MissingRequiredClientCapabilityError`.
14+
# - `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server
15+
# does not support (`error.data: { supported: [...], requested: "..." }`). Raised via
16+
# `Server::UnsupportedProtocolVersionError`.
17+
#
18+
# The values come from the spec's MCP-specific error code block, which is allocated sequentially from `-32020`
19+
# toward `-32099`.
1520
#
1621
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
1722
module ErrorCodes
23+
HEADER_MISMATCH = -32020
1824
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
1925
UNSUPPORTED_PROTOCOL_VERSION = -32022
2026
end

lib/mcp/request_envelope.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
# The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575).
5+
# The modern lifecycle has no `initialize` handshake: every request identifies its protocol version,
6+
# client, and client capabilities through reserved `_meta` keys, and the server validates
7+
# each request independently. Servers MUST NOT infer capabilities from prior requests,
8+
# which is why the envelope is a per-request value object rather than session state.
9+
#
10+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
11+
class RequestEnvelope
12+
PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"
13+
CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"
14+
CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"
15+
16+
# Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle.
17+
# Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format.
18+
LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"
19+
20+
REQUIRED_META_KEYS = [
21+
PROTOCOL_VERSION_META_KEY,
22+
CLIENT_INFO_META_KEY,
23+
CLIENT_CAPABILITIES_META_KEY,
24+
].freeze
25+
26+
class << self
27+
# A request is classified as modern only when the full REQUIRED triple is present,
28+
# matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`.
29+
# A partial triple is treated as legacy so existing `_meta` usage (`progressToken`, trace context) keeps
30+
# flowing through the legacy path.
31+
def modern?(params)
32+
meta = extract_meta(params)
33+
return false unless meta.is_a?(Hash)
34+
35+
REQUIRED_META_KEYS.all? { |key| !read(meta, key).nil? }
36+
end
37+
38+
# Parses and validates the envelope. `request` is only used to enrich the raised error;
39+
# callers dispatching notifications can omit it.
40+
def parse!(params, request: nil)
41+
meta = extract_meta(params)
42+
meta = {} unless meta.is_a?(Hash)
43+
44+
protocol_version = read(meta, PROTOCOL_VERSION_META_KEY)
45+
client_info = read(meta, CLIENT_INFO_META_KEY)
46+
client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY)
47+
48+
unless protocol_version.is_a?(String) && client_info.is_a?(Hash) && client_capabilities.is_a?(Hash)
49+
raise Server::RequestHandlerError.new(
50+
"Invalid Request: modern requests require `#{REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
51+
request,
52+
error_type: :invalid_request,
53+
)
54+
end
55+
56+
unless Configuration.modern_protocol_version?(protocol_version)
57+
raise Server::UnsupportedProtocolVersionError.new(protocol_version, request)
58+
end
59+
60+
new(
61+
protocol_version: protocol_version,
62+
client_info: client_info,
63+
client_capabilities: client_capabilities,
64+
log_level: read(meta, LOG_LEVEL_META_KEY),
65+
)
66+
end
67+
68+
private
69+
70+
# `Server#handle` accepts hashes parsed with either symbol or string keys, so read both forms
71+
# (the same tolerance as `Server#handle_cancelled_notification`).
72+
def extract_meta(params)
73+
return unless params.is_a?(Hash)
74+
75+
meta = params[:_meta]
76+
meta.nil? ? params["_meta"] : meta
77+
end
78+
79+
def read(meta, key)
80+
value = meta[key.to_sym]
81+
value.nil? ? meta[key] : value
82+
end
83+
end
84+
85+
attr_reader :protocol_version, :client_info, :client_capabilities, :log_level
86+
87+
def initialize(protocol_version:, client_info:, client_capabilities:, log_level: nil)
88+
@protocol_version = protocol_version
89+
@client_info = client_info
90+
@client_capabilities = client_capabilities
91+
@log_level = log_level
92+
freeze
93+
end
94+
end
95+
end

lib/mcp/server.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,41 @@ def initialize(elicitations)
5959
end
6060
end
6161

62+
# Raised when a request carries a protocol version the server does not support under the stateless lifecycle of
63+
# MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }`
64+
# so the client can select a mutually supported version and retry.
65+
#
66+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
67+
class UnsupportedProtocolVersionError < RequestHandlerError
68+
def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS)
69+
super(
70+
"Unsupported protocol version",
71+
request,
72+
error_type: :unsupported_protocol_version,
73+
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
74+
error_data: { supported: supported, requested: requested || "unknown" },
75+
)
76+
end
77+
end
78+
79+
# Raised when processing a request requires a client capability the request did not declare in `_meta`
80+
# (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities
81+
# the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }`
82+
# listing the missing capabilities.
83+
#
84+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
85+
class MissingRequiredClientCapabilityError < RequestHandlerError
86+
def initialize(required_capabilities, request = nil)
87+
super(
88+
"Missing required client capability",
89+
request,
90+
error_type: :missing_required_client_capability,
91+
error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
92+
error_data: { requiredCapabilities: required_capabilities },
93+
)
94+
end
95+
end
96+
6297
# Raised when a requested resource URI does not exist. Per SEP-2164,
6398
# resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602)
6499
# with the requested URI in the error `data` member. Raise this from

test/mcp/configuration_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ class ConfigurationTest < ActiveSupport::TestCase
6666
assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
6767
end
6868

69+
test "exposes the SEP-2575 modern protocol versions" do
70+
assert_equal "2026-07-28", Configuration::LATEST_MODERN_PROTOCOL_VERSION
71+
assert_equal ["2026-07-28"], Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS
72+
assert Configuration.modern_protocol_version?("2026-07-28")
73+
refute Configuration.modern_protocol_version?("2025-11-25")
74+
end
75+
76+
test "raises ArgumentError when setting a modern protocol version" do
77+
# Modern versions (SEP-2575) are never negotiated via `initialize`, so they are deliberately not settable as
78+
# the legacy fallback version.
79+
assert_raises(ArgumentError) do
80+
Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION)
81+
end
82+
end
83+
6984
test "raises ArgumentError when protocol_version is not a boolean value" do
7085
config = Configuration.new
7186
exception = assert_raises(ArgumentError) do

test/mcp/error_codes_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module MCP
66
class ErrorCodesTest < ActiveSupport::TestCase
77
test "exposes the SEP-2575 stateless lifecycle error codes" do
88
# The exact values are wire vocabulary shared with other SDKs.
9+
assert_equal(-32020, ErrorCodes::HEADER_MISMATCH)
910
assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY)
1011
assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION)
1112
end

test/mcp/request_envelope_test.rb

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module MCP
6+
class RequestEnvelopeTest < ActiveSupport::TestCase
7+
test "exposes the reserved SEP-2575 meta key names" do
8+
# The exact strings are wire vocabulary shared with other SDKs.
9+
assert_equal "io.modelcontextprotocol/protocolVersion", RequestEnvelope::PROTOCOL_VERSION_META_KEY
10+
assert_equal "io.modelcontextprotocol/clientInfo", RequestEnvelope::CLIENT_INFO_META_KEY
11+
assert_equal "io.modelcontextprotocol/clientCapabilities", RequestEnvelope::CLIENT_CAPABILITIES_META_KEY
12+
assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY
13+
end
14+
15+
test ".modern? returns true when the full required triple is present" do
16+
assert RequestEnvelope.modern?(modern_params)
17+
end
18+
19+
test ".modern? returns true for string keys" do
20+
params = {
21+
"_meta" => {
22+
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
23+
"io.modelcontextprotocol/clientInfo" => { "name" => "c", "version" => "1" },
24+
"io.modelcontextprotocol/clientCapabilities" => {},
25+
},
26+
}
27+
28+
assert RequestEnvelope.modern?(params)
29+
end
30+
31+
test ".modern? returns false for a partial triple" do
32+
params = modern_params
33+
params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities")
34+
35+
refute RequestEnvelope.modern?(params)
36+
end
37+
38+
test ".modern? returns false for legacy _meta entries such as progressToken" do
39+
refute RequestEnvelope.modern?({ name: "echo", _meta: { progressToken: "token" } })
40+
end
41+
42+
test ".modern? returns false without params or _meta" do
43+
refute RequestEnvelope.modern?(nil)
44+
refute RequestEnvelope.modern?({})
45+
refute RequestEnvelope.modern?({ name: "echo" })
46+
refute RequestEnvelope.modern?({ _meta: nil })
47+
end
48+
49+
test ".parse! returns a frozen envelope with the triple and optional log level" do
50+
params = modern_params
51+
params[:_meta][:"io.modelcontextprotocol/logLevel"] = "warning"
52+
53+
envelope = RequestEnvelope.parse!(params)
54+
55+
assert_predicate envelope, :frozen?
56+
assert_equal "2026-07-28", envelope.protocol_version
57+
assert_equal({ name: "test_client", version: "1.0.0" }, envelope.client_info)
58+
assert_equal({ elicitation: {} }, envelope.client_capabilities)
59+
assert_equal "warning", envelope.log_level
60+
end
61+
62+
test ".parse! leaves log_level nil when absent" do
63+
assert_nil RequestEnvelope.parse!(modern_params).log_level
64+
end
65+
66+
test ".parse! raises UnsupportedProtocolVersionError with the SEP-2575 data shape" do
67+
params = modern_params(version: "2025-11-25")
68+
69+
error = assert_raises(Server::UnsupportedProtocolVersionError) do
70+
RequestEnvelope.parse!(params)
71+
end
72+
73+
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, error.error_code
74+
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported]
75+
assert_equal "2025-11-25", error.error_data[:requested]
76+
end
77+
78+
test ".parse! raises an invalid request error when the triple is incomplete" do
79+
params = modern_params
80+
params[:_meta].delete(:"io.modelcontextprotocol/clientInfo")
81+
82+
error = assert_raises(Server::RequestHandlerError) do
83+
RequestEnvelope.parse!(params)
84+
end
85+
86+
assert_equal :invalid_request, error.error_type
87+
end
88+
89+
test ".parse! raises an invalid request error when a triple member has the wrong type" do
90+
params = modern_params
91+
params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash"
92+
93+
error = assert_raises(Server::RequestHandlerError) do
94+
RequestEnvelope.parse!(params)
95+
end
96+
97+
assert_equal :invalid_request, error.error_type
98+
end
99+
100+
private
101+
102+
def modern_params(version: "2026-07-28")
103+
{
104+
name: "echo",
105+
_meta: {
106+
"io.modelcontextprotocol/protocolVersion": version,
107+
"io.modelcontextprotocol/clientInfo": { name: "test_client", version: "1.0.0" },
108+
"io.modelcontextprotocol/clientCapabilities": { elicitation: {} },
109+
},
110+
}
111+
end
112+
end
113+
end

test/mcp/server_test.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,49 @@ class ServerTest < ActiveSupport::TestCase
172172
end
173173
end
174174

175+
test "UnsupportedProtocolVersionError surfaces as -32022 with the SEP-2575 data shape" do
176+
server = Server.new(name: "error_test", tools: [TestTool])
177+
server.define_tool(name: "unsupported_version_tool") do
178+
raise Server::UnsupportedProtocolVersionError, "1900-01-01"
179+
end
180+
181+
response = server.handle({
182+
jsonrpc: "2.0",
183+
id: 1,
184+
method: "tools/call",
185+
params: { name: "unsupported_version_tool" },
186+
})
187+
188+
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
189+
assert_equal "Unsupported protocol version", response.dig(:error, :message)
190+
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported)
191+
assert_equal "1900-01-01", response.dig(:error, :data, :requested)
192+
end
193+
194+
test "UnsupportedProtocolVersionError reports an unknown requested version" do
195+
error = Server::UnsupportedProtocolVersionError.new(nil)
196+
197+
assert_equal "unknown", error.error_data[:requested]
198+
end
199+
200+
test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do
201+
server = Server.new(name: "error_test", tools: [TestTool])
202+
server.define_tool(name: "missing_capability_tool") do
203+
raise Server::MissingRequiredClientCapabilityError, { elicitation: {} }
204+
end
205+
206+
response = server.handle({
207+
jsonrpc: "2.0",
208+
id: 1,
209+
method: "tools/call",
210+
params: { name: "missing_capability_tool" },
211+
})
212+
213+
assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code)
214+
assert_equal "Missing required client capability", response.dig(:error, :message)
215+
assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities))
216+
end
217+
175218
test "#handle initialize request returns protocol info, server info, and capabilities" do
176219
request = {
177220
jsonrpc: "2.0",

0 commit comments

Comments
 (0)