Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions lib/mcp/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 14 additions & 8 deletions lib/mcp/error_codes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions lib/mcp/request_envelope.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions test/mcp/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/mcp/error_codes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions test/mcp/request_envelope_test.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down