diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651d..c06954fa 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -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, @@ -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 diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 699bcb7a..bb599d31 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -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. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 23acceaa..b5ed1ec2 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -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). @@ -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)