Handle the SEP-2575 Modern Request Envelope in the Server Core
## Motivation and Context
Second step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release, building on the `MCP::RequestEnvelope` foundations.
`Server#handle_request` now lifts the per-request `_meta` envelope before dispatch: a request
whose `_meta` carries the full required triple is validated (raising `-32022` with
`data: { supported:, requested: }` for unsupported versions) and its envelope is threaded into
`tools/call`, `prompts/get`, `completion/complete`, `resources/read`, subscribe/unsubscribe,
and custom method handlers via `MCP::ServerContext`. A partial triple keeps flowing through
the legacy path untouched, so existing `_meta` usage (`progressToken`, trace context) is unaffected.
`MCP::ServerContext` gains per-request readers and a capability guard:
- `modern?`, `protocol_version`, `client_info`, and `client_capabilities` read from the envelope first
and fall back to session state stored by `initialize` on legacy requests. The envelope always wins
because servers MUST NOT infer client state from prior requests; nothing is written to the session
on the modern path.
- `require_client_capability!(*path)` raises `Server::MissingRequiredClientCapabilityError`
(`-32021` with `data: { requiredCapabilities: }`) when the request did not declare the capability.
- `notify_log_message` honors the per-request `io.modelcontextprotocol/logLevel`: on modern requests without it,
the server MUST NOT send `notifications/message`, and an insufficient level drops the message the same way.
`MCP::ServerSession` gains the connection-era lock of the dual-era serving model: `era` is `nil`
until the first era-distinctive message succeeds, a successful `initialize` locks `:legacy` as
a side effect of `mark_initialized!`, `lock_era!` refuses to flip an established era, and transports can construct
per-request sessions with `era: :modern`. On a modern-locked session, `initialize` is rejected with `-32022`
(the modern lifecycle has no handshake) and the envelope triple becomes required for every other request
except `server/discover`.
The existing plain `RuntimeError` raises in `ServerSession#list_roots` and friends are intentionally unchanged:
converting them to `RequestHandlerError` subclasses would break callers rescuing `RuntimeError`.
Typed `-32021` errors are scoped to the new envelope-based guard.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover the wire behavior through `Server#handle`: envelope data exposure
without session mutation, `-32022` for unsupported envelope versions, partial triples staying legacy,
the envelope requirement and `initialize` rejection on modern-locked sessions, the `server/discover` exemption,
`require_client_capability!` returning `-32021` with the `requiredCapabilities` data shape,
and the `ServerSession` era-lock transitions (including preset `era: :modern` and invalid values).
New tests in `test/mcp/server_context_test.rb` cover the per-request logLevel gate (absent, insufficient,
and sufficient levels), the envelope-first/session-fallback readers, and string-keyed capability matching
in `require_client_capability!`.
## Breaking Changes
None. All new keyword arguments default to `nil`, legacy requests take exactly the same code path as before,
and the era lock only constrains sequences that were previously impossible (modern-era traffic).
Fix a Ruby 2.7 ArgumentError When Raising UnsupportedProtocolVersionError
## Motivation and Context
On Ruby 2.7, a method that declares a keyword parameter splits a trailing
symbol-keyed Hash positional argument into keywords. `UnsupportedProtocolVersionError#initialize`
declared `supported:`, so passing the request Hash as the second positional argument raised
`ArgumentError: unknown keywords: :name, :arguments, :_meta` inside the error constructor,
and the intended `-32022` response surfaced as a `-32603` internal error on the Ruby 2.7 CI job.
No caller overrides `supported:`; drop the keyword parameter and read
`Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS` directly, matching the keyword-free
shape of `ResourceNotFoundError` and `MissingRequiredClientCapabilityError`.
## How Has This Been Tested?
- Reproduced the `-32603` responses on Ruby 2.7.8 and confirmed both now return `-32022`
- Added a regression test constructing the error with a symbol-keyed request Hash
- `bundle exec rake test` on Ruby 2.7.8: 1467 runs, 0 failures
## Breaking Changes
None. The `supported:` keyword argument was never passed by any caller.
This commit is contained in:
parent
75fffab8c8
commit
5b4729294c
2 changed files with 394 additions and 18 deletions
|
|
@ -311,6 +311,71 @@ module MCP
|
|||
assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") }
|
||||
end
|
||||
|
||||
test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do
|
||||
# Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send
|
||||
# any `notifications/message` for the request.
|
||||
notification_target = mock
|
||||
notification_target.expects(:notify_log_message).never
|
||||
server_context = build_modern_server_context(notification_target, log_level: nil)
|
||||
|
||||
server_context.notify_log_message(data: "test", level: "error")
|
||||
end
|
||||
|
||||
test "ServerContext#notify_log_message drops messages below the modern request logLevel" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:notify_log_message).never
|
||||
server_context = build_modern_server_context(notification_target, log_level: "error")
|
||||
|
||||
server_context.notify_log_message(data: "test", level: "info")
|
||||
end
|
||||
|
||||
test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:notify_log_message).with(
|
||||
data: "test", level: "error", logger: nil, related_request_id: nil,
|
||||
).once
|
||||
server_context = build_modern_server_context(notification_target, log_level: "warning")
|
||||
|
||||
server_context.notify_log_message(data: "test", level: "error")
|
||||
end
|
||||
|
||||
test "ServerContext exposes envelope data and falls back to the session on legacy requests" do
|
||||
notification_target = mock
|
||||
notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" })
|
||||
notification_target.stubs(:client_capabilities).returns({ roots: {} })
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
|
||||
legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
|
||||
modern_context = build_modern_server_context(notification_target)
|
||||
|
||||
refute_predicate legacy_context, :modern?
|
||||
assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info)
|
||||
assert_equal({ roots: {} }, legacy_context.client_capabilities)
|
||||
assert_nil legacy_context.protocol_version
|
||||
|
||||
assert_predicate modern_context, :modern?
|
||||
assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info)
|
||||
assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities)
|
||||
assert_equal "2026-07-28", modern_context.protocol_version
|
||||
end
|
||||
|
||||
test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do
|
||||
envelope = RequestEnvelope.new(
|
||||
protocol_version: "2026-07-28",
|
||||
client_info: { name: "c", version: "1" },
|
||||
client_capabilities: { "elicitation" => { "form" => {} } },
|
||||
)
|
||||
progress = Progress.new(notification_target: nil, progress_token: nil)
|
||||
server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope)
|
||||
|
||||
assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) }
|
||||
assert_raises(ArgumentError) { server_context.require_client_capability! }
|
||||
error = assert_raises(Server::MissingRequiredClientCapabilityError) do
|
||||
server_context.require_client_capability!(:sampling)
|
||||
end
|
||||
assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data)
|
||||
end
|
||||
|
||||
test "ServerContext#notify_resources_updated delegates to notification_target" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
|
||||
|
|
@ -1000,5 +1065,18 @@ module MCP
|
|||
assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World",
|
||||
response[:result][:messages][0][:content][:text]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_modern_server_context(notification_target, log_level: nil)
|
||||
envelope = RequestEnvelope.new(
|
||||
protocol_version: "2026-07-28",
|
||||
client_info: { name: "modern_client", version: "2.0" },
|
||||
client_capabilities: { elicitation: { form: {} } },
|
||||
log_level: log_level,
|
||||
)
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -197,6 +197,17 @@ module MCP
|
|||
assert_equal "unknown", error.error_data[:requested]
|
||||
end
|
||||
|
||||
test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do
|
||||
# On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split
|
||||
# into keywords and raise an ArgumentError.
|
||||
request = { name: "echo", arguments: {}, _meta: {} }
|
||||
|
||||
error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request)
|
||||
|
||||
assert_equal "1900-01-01", error.error_data[:requested]
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported]
|
||||
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
|
||||
|
|
@ -215,6 +226,152 @@ module MCP
|
|||
assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities))
|
||||
end
|
||||
|
||||
test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do
|
||||
server = Server.new(name: "modern_test", tools: [])
|
||||
received = nil
|
||||
server.define_tool(name: "modern_tool") do |server_context:|
|
||||
received = {
|
||||
modern: server_context.modern?,
|
||||
client_info: server_context.client_info,
|
||||
client_capabilities: server_context.client_capabilities,
|
||||
protocol_version: server_context.protocol_version,
|
||||
}
|
||||
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||
end
|
||||
session = ServerSession.new(server: server, transport: mock)
|
||||
|
||||
response = server.handle(
|
||||
modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }),
|
||||
session: session,
|
||||
)
|
||||
|
||||
refute_nil response[:result]
|
||||
assert received[:modern]
|
||||
assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info])
|
||||
assert_equal({ elicitation: {} }, received[:client_capabilities])
|
||||
assert_equal "2026-07-28", received[:protocol_version]
|
||||
# Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored.
|
||||
assert_nil session.client
|
||||
refute_predicate session, :initialized?
|
||||
end
|
||||
|
||||
test "#handle tools/call with an unsupported envelope version returns -32022" do
|
||||
server = Server.new(name: "modern_test", tools: [])
|
||||
called = false
|
||||
server.define_tool(name: "modern_tool") do
|
||||
called = true
|
||||
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||
end
|
||||
|
||||
response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01"))
|
||||
|
||||
refute called
|
||||
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported)
|
||||
assert_equal "2027-01-01", response.dig(:error, :data, :requested)
|
||||
end
|
||||
|
||||
test "#handle treats a partial modern triple as a legacy request" do
|
||||
server = Server.new(name: "modern_test", tools: [])
|
||||
received_context = nil
|
||||
server.define_tool(name: "modern_tool") do |server_context:|
|
||||
received_context = server_context
|
||||
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||
end
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
id: 1,
|
||||
params: {
|
||||
name: "modern_tool",
|
||||
arguments: {},
|
||||
_meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
|
||||
},
|
||||
})
|
||||
|
||||
refute_nil response[:result]
|
||||
refute_predicate received_context, :modern?
|
||||
end
|
||||
|
||||
test "#handle requires the envelope for requests on a modern-locked session" do
|
||||
server = Server.new(name: "modern_test", tools: [])
|
||||
server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) }
|
||||
session = ServerSession.new(server: server, transport: mock, era: :modern)
|
||||
|
||||
response = server.handle(
|
||||
{ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } },
|
||||
session: session,
|
||||
)
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code)
|
||||
end
|
||||
|
||||
test "#handle rejects initialize on a modern-locked session with -32022" do
|
||||
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||
|
||||
response = @server.handle(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: 1,
|
||||
params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } },
|
||||
},
|
||||
session: session,
|
||||
)
|
||||
|
||||
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
|
||||
assert_equal "2025-11-25", response.dig(:error, :data, :requested)
|
||||
end
|
||||
|
||||
test "#handle server/discover succeeds without an envelope on a modern-locked session" do
|
||||
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||
|
||||
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session)
|
||||
|
||||
refute_nil response[:result]
|
||||
end
|
||||
|
||||
test "#handle tools/call enforces require_client_capability! from the envelope" do
|
||||
server = Server.new(name: "modern_test", tools: [])
|
||||
server.define_tool(name: "guarded_tool") do |server_context:|
|
||||
server_context.require_client_capability!(:elicitation, :form)
|
||||
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||
end
|
||||
|
||||
declared = server.handle(
|
||||
modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }),
|
||||
)
|
||||
undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" }))
|
||||
|
||||
refute_nil declared[:result]
|
||||
assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code)
|
||||
assert_equal(
|
||||
{ elicitation: { form: {} } },
|
||||
undeclared.dig(:error, :data, :requiredCapabilities),
|
||||
)
|
||||
end
|
||||
|
||||
test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
assert_nil session.era
|
||||
session.mark_initialized!
|
||||
assert_equal :legacy, session.era
|
||||
|
||||
session.lock_era!(:legacy)
|
||||
assert_equal :legacy, session.era
|
||||
assert_raises(RuntimeError) { session.lock_era!(:modern) }
|
||||
end
|
||||
|
||||
test "ServerSession accepts a preset era and validates era values" do
|
||||
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||
|
||||
assert_equal :modern, session.era
|
||||
assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) }
|
||||
assert_raises(ArgumentError) { session.lock_era!(:future) }
|
||||
end
|
||||
|
||||
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -3726,5 +3883,23 @@ module MCP
|
|||
|
||||
refute Apps.client_supports?(server.client_capabilities)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds a request carrying the SEP-2575 modern `_meta` envelope.
|
||||
def modern_request(method, params, version: "2026-07-28", capabilities: {})
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: method,
|
||||
id: 1,
|
||||
params: params.merge(
|
||||
_meta: {
|
||||
"io.modelcontextprotocol/protocolVersion": version,
|
||||
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
|
||||
"io.modelcontextprotocol/clientCapabilities": capabilities,
|
||||
},
|
||||
),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue