ruby-sdk/lib/mcp/request_envelope.rb
Koichi ITO f39b1bf4aa Align modern envelope validation with the finalized spec
## Motivation and Context

Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation,
both introduced against the frozen SEP text and overtaken by post-final spec changes:

1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it
optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only
the `protocolVersion` + `clientCapabilities` pair.
A conformant client configured not to identify itself was rejected.

2. A missing or mistyped envelope answered `-32600` Invalid Request.
The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer
with `-32602` naming the offending keys.

Fixing these also corrects the era classification to match the reference SDKs: a request claims
the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion`
(the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks),
and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming
the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without
the claim key (`progressToken`, trace context) is classified exactly as before.

Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on
a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK.

## How Has This Been Tested?

`test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone,
a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo`
(reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities`
and for mistyped required and optional fields.

`test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed
but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is
served with `server_context.client_info` reading `nil`, and a claim-less request on
a modern-locked session answers `-32602` naming the required keys.
The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.

## Breaking Changes

None for conforming clients. Requests that were already rejected change error code
(`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key
with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests.
Envelopes without `clientInfo`, previously rejected, now succeed.
2026-08-08 16:00:29 +09:00

113 lines
5.1 KiB
Ruby

# 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
# 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
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"
# Result-side counterpart of the request envelope: the server's identity rides in
# the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was
# 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_CAPABILITIES_META_KEY,
].freeze
class << self
# 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)
!read(meta, PROTOCOL_VERSION_META_KEY).nil?
end
# 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)
protocol_version = read(meta, PROTOCOL_VERSION_META_KEY)
client_info = read(meta, CLIENT_INFO_META_KEY)
client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY)
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 params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`",
request,
error_type: :invalid_params,
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
)
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
# `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_capabilities:, client_info: nil, log_level: nil)
@protocol_version = protocol_version
@client_info = client_info
@client_capabilities = client_capabilities
@log_level = log_level
freeze
end
end
end