Skip to content
Merged
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
23 changes: 20 additions & 3 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -565,9 +565,9 @@ def handle_request(request, method, session: nil, related_request_id: nil)
end

# SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found
# there regardless of the envelope or the server's declared capabilities: in this era the method
# does not exist at all, which is why the check precedes `ensure_capability!`.
if session.respond_to?(:era) && session.era == :modern && Methods::MODERN_REMOVED_METHODS.include?(method)
# there regardless of the server's declared capabilities: in this era the method does not exist
# at all, which is why the check precedes `ensure_capability!`.
if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session)
raise RequestHandlerError.new(
"Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
request,
Expand Down Expand Up @@ -677,6 +677,23 @@ def handle_request(request, method, session: nil, related_request_id: nil)
}
end

# Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it.
# A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is
# the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds,
# which would otherwise let the first request of a connection reach a method the modern lifecycle does not have.
# The Python SDK gates the same way, on the envelope of each request rather than on connection state that
# is only settled afterwards.
#
# A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there
# with the lifecycle violation, which names the cause better than Method not found.
def modern_request?(request, session)
era = session.respond_to?(:era) ? session.era : nil
return true if era == :modern
return false unless era.nil?

RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
end

# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
Expand Down
23 changes: 23 additions & 0 deletions test/mcp/server/transports/stdio_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,29 @@ class StdioTransportTest < ActiveSupport::TestCase
assert_equal :modern, session_era
end

test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do
# The era locks only after a response succeeds, so this frame reaches the server unlocked.
# SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern.
responses = run_transport_session([
{
jsonrpc: "2.0",
method: "resources/subscribe",
id: 1,
params: {
uri: "https://example.invalid/resource",
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
},
])

assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
refute_equal :legacy, session_era
end

test "does not lock an era when the era-distinctive request fails" do
# An unsupported envelope version fails with -32022, so the connection stays unlocked
# and a legacy initialize can still succeed afterwards.
Expand Down
54 changes: 53 additions & 1 deletion test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,56 @@ class ServerTest < ActiveSupport::TestCase
end
end

test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do
# `StdioTransport` locks the era only after a response succeeds, so the first request of a connection
# arrives with `era == nil`. The envelope on the request is what identifies it as modern there.
Methods::MODERN_REMOVED_METHODS.each do |method|
session = ServerSession.new(server: @server, transport: mock)

response = @server.handle(modern_request(method, {}), session: session)

assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
end
end

test "#handle does not let an enveloped initialize lock the era before it is settled" do
# Serving it would negotiate the legacy lifecycle for a request that declared the modern one,
# pinning the connection to the wrong era for its whole lifetime.
session = ServerSession.new(server: @server, transport: mock)

@server.handle(modern_request("initialize", initialize_params), session: session)

assert_nil session.era
refute_predicate session, :initialized?
end

test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do
session = ServerSession.new(server: @server, transport: mock)

response = @server.handle(modern_request("tools/list", {}), session: session)

refute_nil response[:result]
end

test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do
# Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope:
# the handshake still belongs to the legacy lifecycle and locks that era.
session = ServerSession.new(server: @server, transport: mock)

response = @server.handle(
{
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: initialize_params(protocolVersion: "2026-07-28"),
},
session: session,
)

assert_equal "2026-07-28", response.dig(:result, :protocolVersion)
assert_equal :legacy, session.era
end

test "#handle server/discover requires the envelope on a modern-locked session" do
# The 2026-07-28 conformance requirements reject an envelope-less `server/discover`
# on the modern era with -32602 (SEP-2575).
Expand Down Expand Up @@ -4255,9 +4305,11 @@ def server_context
test "modern results carry resultType complete" do
server = Server.new(name: "result_type_test", tools: [result_type_tool])

# Every method here has to be one the modern lifecycle still has. `ping` does not qualify:
# SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result.
[
modern_request(Methods::TOOLS_LIST, {}),
modern_request(Methods::PING, {}),
modern_request(Methods::PROMPTS_LIST, {}),
modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }),
].each do |request|
response = server.handle(request)
Expand Down