diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index afb3e845..340f27e2 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -645,10 +645,18 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_ begin Methods.ensure_capability!(method, capabilities) rescue Methods::MissingRequiredCapabilityError => e - # Re-raise through RequestHandlerError, the one channel whose message is - # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError` - # no longer echoes exception messages into the error `data` member (CWE-209). - raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e) + # A method of a capability the server does not declare is one the server does not implement, so it is answered as Method not found, + # which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to + # 404 on the modern lifecycle. Re-raised through RequestHandlerError, the one channel whose message is deliberately surfaced to clients: + # `JsonRpcHandler`'s blind `rescue StandardError` no longer echoes exception messages into the error `data` member (CWE-209). + raise RequestHandlerError.new( + "Method not found", + request, + error_type: :method_not_found, + error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + error_data: e.message, + original_error: e, + ) end # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 1e1ebc7a..67df6584 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6047,6 +6047,55 @@ def string assert_equal(-32601, JSON.parse(response[2][0]).dig("error", "code")) end + test "modern POST maps a method of an undeclared capability to 404 with -32601" do + server = Server.new(name: "tools_only", capabilities: { tools: {} }) + transport = StreamableHTTPTransport.new(server) + + response = transport.handle_request(modern_rack_request(modern_body("prompts/list", {}))) + + assert_equal 404, response[0] + + body = JSON.parse(response[2][0]) + + assert_equal(-32601, body.dig("error", "code")) + assert_equal "Server does not support prompts (required for prompts/list)", body.dig("error", "data") + assert_equal 1, body["id"] + ensure + transport.close + end + + test "legacy POST answers a method of an undeclared capability with 200 and -32601" do + server = Server.new(name: "tools_only", capabilities: { tools: {} }) + transport = StreamableHTTPTransport.new(server) + init_response = transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + )) + session_id = init_response[1]["mcp-session-id"] + + response = transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "prompts/list", id: 1 }.to_json, + )) + + assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) + + assert_equal(-32601, body.dig("error", "code")) + assert_equal "Method not found", body.dig("error", "message") + assert_equal "Server does not support prompts (required for prompts/list)", body.dig("error", "data") + assert_equal 1, body["id"] + ensure + transport.close + end + test "modern POST requires the _meta envelope" do response = @transport.handle_request(modern_rack_request( { jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json, diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c1ac383f..1819a5e6 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -1880,23 +1880,32 @@ def read_resource_request(uri) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_equal(-32603, response[:error][:code]) + assert_equal(-32601, response[:error][:code]) + assert_equal "Method not found", response[:error][:message] assert_includes response[:error][:data], "Server does not support logging" end - test "#handle method with missing required top-level capability returns an error" do + test "#handle method with missing required top-level capability returns Method not found" do + # A method of an undeclared capability is one the server does not implement, as the TypeScript and Python + # servers answer for a handler they never registered; -32603 would read as a crash. @server.capabilities = {} response = @server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) + assert_equal(-32601, response[:error][:code]) + assert_equal "Method not found", response[:error][:message] assert_equal "Server does not support prompts (required for prompts/list)", response[:error][:data] response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal(-32601, response[:error][:code]) + assert_equal "Method not found", response[:error][:message] assert_equal "Server does not support resources (required for resources/list)", response[:error][:data] end - test "#handle method with missing required nested capability returns an error" do + test "#handle method with missing required nested capability returns Method not found" do @server.capabilities = { resources: {} } response = @server.handle({ jsonrpc: "2.0", method: "resources/subscribe", id: 1 }) + assert_equal(-32601, response[:error][:code]) + assert_equal "Method not found", response[:error][:message] assert_equal "Server does not support resources.subscribe (required for resources/subscribe)", response[:error][:data] end