Merge pull request #491 from koic/align_modern_envelope_validation_with_final_spec

Align modern envelope validation with the finalized spec
This commit is contained in:
Koichi ITO 2026-08-08 16:04:45 +09:00 • committed by GitHub
commit 05c3585bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 109 additions and 31 deletions

View file

@ -2,10 +2,10 @@
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.
# The modern lifecycle has no `initialize` handshake: every request identifies its protocol version
# and client capabilities through reserved `_meta` keys (plus an optional client identity),
# 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
@ -22,26 +22,31 @@ module MCP
# finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it.
SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
# `clientInfo` is deliberately absent: it became optional after the SEP was finalized
# (spec PR modelcontextprotocol/modelcontextprotocol#3002), so servers MUST accept
# envelopes without it. The TypeScript and Python SDKs validate the same required pair.
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.
# A request claims the modern lifecycle when its `_meta` carries `io.modelcontextprotocol/protocolVersion`,
# matching the TypeScript SDK's envelope claim and the Python SDK's `_has_modern_envelope`.
# Classification is deliberately looser than validation: a claimed-but-malformed envelope is
# rejected by {parse!} with `-32602` instead of silently flowing through the legacy path,
# while `_meta` without the claim key (`progressToken`, trace context) stays legacy.
def modern?(params)
meta = extract_meta(params)
return false unless meta.is_a?(Hash)
REQUIRED_META_KEYS.all? { |key| !read(meta, key).nil? }
!read(meta, PROTOCOL_VERSION_META_KEY).nil?
end
# Parses and validates the envelope. `request` is only used to enrich the raised error;
# callers dispatching notifications can omit it.
# Parses and validates the envelope: `protocolVersion` and `clientCapabilities` are required,
# `clientInfo` is optional. A missing or mistyped field is Invalid params (`-32602`) naming
# the offending keys, the code and shape the spec mandates and the reference SDKs emit.
# `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)
@ -50,11 +55,17 @@ module MCP
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)
invalid_keys = []
invalid_keys << PROTOCOL_VERSION_META_KEY unless protocol_version.is_a?(String)
invalid_keys << CLIENT_CAPABILITIES_META_KEY unless client_capabilities.is_a?(Hash)
invalid_keys << CLIENT_INFO_META_KEY unless client_info.nil? || client_info.is_a?(Hash)
unless invalid_keys.empty?
raise Server::RequestHandlerError.new(
"Invalid Request: modern requests require `#{REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
"Invalid params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`",
request,
error_type: :invalid_request,
error_type: :invalid_params,
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
)
end
@ -87,9 +98,11 @@ module MCP
end
end
# `client_info` is `nil` when the client chose not to identify itself, which is legal:
# it is self-reported data and MUST NOT drive behavior or security decisions anyway.
attr_reader :protocol_version, :client_info, :client_capabilities, :log_level
def initialize(protocol_version:, client_info:, client_capabilities:, log_level: nil)
def initialize(protocol_version:, client_capabilities:, client_info: nil, log_level: nil)
@protocol_version = protocol_version
@client_info = client_info
@client_capabilities = client_capabilities

View file

@ -689,10 +689,14 @@ module MCP
RequestEnvelope.parse!(params, request: params)
elsif era == :modern
# A claim-less request on a modern session is a malformed envelope, not a malformed request:
# the spec maps missing required envelope fields to Invalid params (`-32602`),
# and the reference SDKs answer it naming the missing keys.
raise RequestHandlerError.new(
"Invalid Request: modern sessions require the SEP-2575 `_meta` envelope",
"Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
params,
error_type: :invalid_request,
error_type: :invalid_params,
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
)
end
end

View file

@ -12,8 +12,9 @@ module MCP
assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY
end
test ".modern? returns true when the full required triple is present" do
test ".modern? returns true when the protocolVersion claim key is present" do
assert RequestEnvelope.modern?(modern_params)
assert RequestEnvelope.modern?({ _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } })
end
test ".modern? returns true for string keys" do
@ -28,11 +29,13 @@ module MCP
assert RequestEnvelope.modern?(params)
end
test ".modern? returns false for a partial triple" do
test ".modern? stays true for a claimed but incomplete envelope" do
# A claimed envelope must be validated by `parse!` (`-32602`), never silently served as legacy,
# matching the TypeScript and Python classifiers.
params = modern_params
params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities")
refute RequestEnvelope.modern?(params)
assert RequestEnvelope.modern?(params)
end
test ".modern? returns false for legacy _meta entries such as progressToken" do
@ -75,18 +78,34 @@ module MCP
assert_equal "2025-11-25", error.error_data[:requested]
end
test ".parse! raises an invalid request error when the triple is incomplete" do
test ".parse! accepts an envelope without the optional clientInfo" do
# `clientInfo` became optional after the SEP was finalized (spec PR #3002);
# a conformant client configured not to identify itself must not be rejected.
params = modern_params
params[:_meta].delete(:"io.modelcontextprotocol/clientInfo")
envelope = RequestEnvelope.parse!(params)
assert_nil envelope.client_info
assert_equal "2026-07-28", envelope.protocol_version
assert_equal({ elicitation: {} }, envelope.client_capabilities)
end
test ".parse! raises Invalid params naming the missing key when clientCapabilities is absent" do
params = modern_params
params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities")
error = assert_raises(Server::RequestHandlerError) do
RequestEnvelope.parse!(params)
end
assert_equal :invalid_request, error.error_type
assert_equal :invalid_params, error.error_type
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code
assert_includes error.message, "io.modelcontextprotocol/clientCapabilities"
refute_includes error.message, "io.modelcontextprotocol/protocolVersion"
end
test ".parse! raises an invalid request error when a triple member has the wrong type" do
test ".parse! raises Invalid params when a required field has the wrong type" do
params = modern_params
params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash"
@ -94,7 +113,20 @@ module MCP
RequestEnvelope.parse!(params)
end
assert_equal :invalid_request, error.error_type
assert_equal :invalid_params, error.error_type
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code
end
test ".parse! raises Invalid params when the optional clientInfo has the wrong type" do
params = modern_params
params[:_meta][:"io.modelcontextprotocol/clientInfo"] = "not-a-hash"
error = assert_raises(Server::RequestHandlerError) do
RequestEnvelope.parse!(params)
end
assert_equal :invalid_params, error.error_type
assert_includes error.message, "io.modelcontextprotocol/clientInfo"
end
private

View file

@ -617,7 +617,7 @@ module MCP
])
refute responses[0].key?(:error)
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
end
test "#send_request raises on a modern-locked session" do

View file

@ -5425,7 +5425,7 @@ module MCP
))
assert_equal 400, response[0]
assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code"))
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST maps a missing client capability to 400 with -32021" do

View file

@ -295,7 +295,30 @@ module MCP
assert_equal "2027-01-01", response.dig(:error, :data, :requested)
end
test "#handle treats a partial modern triple as a legacy request" do
test "#handle rejects a claimed but incomplete envelope with -32602 naming the missing key" do
# A request carrying the `protocolVersion` claim key must be validated, never silently served as legacy:
# the spec maps missing required envelope fields to Invalid params, and the TypeScript and Python classifiers
# answer the same way.
server = Server.new(name: "modern_test", tools: [])
server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) }
response = server.handle({
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: {
name: "modern_tool",
arguments: {},
_meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
},
})
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code)
assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities"
end
test "#handle serves an envelope without the optional clientInfo" do
# `clientInfo` became optional after the SEP was finalized (spec PR #3002).
server = Server.new(name: "modern_test", tools: [])
received_context = nil
server.define_tool(name: "modern_tool") do |server_context:|
@ -310,12 +333,17 @@ module MCP
params: {
name: "modern_tool",
arguments: {},
_meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { elicitation: {} },
},
},
})
refute_nil response[:result]
refute_predicate received_context, :modern?
assert_predicate received_context, :modern?
assert_nil received_context.client_info
assert_equal({ elicitation: {} }, received_context.client_capabilities)
end
test "#handle requires the envelope for requests on a modern-locked session" do
@ -328,7 +356,8 @@ module MCP
session: session,
)
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code)
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code)
assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion"
end
test "#handle rejects initialize on a modern-locked session with -32022" do