From 7f4ad488cbac30c98b4118a5b300bb9a98583fb2 Mon Sep 17 00:00:00 2001 From: Dominik Klein Date: Wed, 14 Oct 2026 22:13:32 +0200 Subject: [PATCH] Omit the id of an error response when it is unknown MCP's error responses have an optional string or integer id, so an id the server couldn't read is now left out instead of being sent as null. Fixes #606. --- lib/json_rpc_handler.rb | 2 +- .../transports/streamable_http_transport.rb | 4 ++-- test/json_rpc_handler_test.rb | 19 ++++++++------- .../streamable_http_transport_test.rb | 23 +++++++++---------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 6cbce1cb..8df32fb4 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -202,6 +202,6 @@ def error_response(id:, id_validation_pattern:, error:) jsonrpc: Version::V2_0, id: valid_id?(id, id_validation_pattern) ? id : nil, error: error.compact, - } unless id.nil? + }.compact unless id.nil? end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index ef85ef2f..e676f3fa 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -2033,7 +2033,7 @@ def validate_protocol_version_header(request) def json_rpc_error_response(status:, code:, message:, data: nil, id: nil) error = { code: code, message: message } error[:data] = data if data - body = { jsonrpc: "2.0", id: id, error: error } + body = { jsonrpc: "2.0", id: id, error: error }.compact [status, { "content-type" => "application/json" }, [body.to_json]] end @@ -2486,7 +2486,7 @@ def invalid_request_response(message, request_id: nil) code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, message: message, }, - } + }.compact [400, { "content-type" => "application/json" }, [body.to_json]] end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 07db0ecf..7556df8c 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -290,6 +290,8 @@ # If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be # Null. # + # MCP leaves the id out instead: an error response has an optional string or integer id, never null. + # # Either the result member or error member MUST be included, but both members MUST NOT be included. it "returns a result object and no error object on success" do @@ -315,7 +317,7 @@ assert_nil @response[:result] end - it "returns nil for id when there is an error and and error detecting the id" do + it "omits the id when there is an error and an error detecting the id" do register("ping") { "pong" } handle jsonrpc: "2.0", id: {}, method: "ping" @@ -325,7 +327,7 @@ message: "Invalid Request", data: "Request ID must match validation pattern, or be an integer or null", } - assert_nil @response[:id] + refute @response.key?(:id) end it "returns the same request id on an Invalid Request error when the id is detectable" do @@ -339,7 +341,7 @@ assert_equal 3, @response[:id] end - it "returns nil for id on an Invalid Request error when the request has no id" do + it "omits the id on an Invalid Request error when the request has no id" do handle jsonrpc: "1.0", method: "ping", params: {} assert_rpc_error expected_error: { @@ -347,10 +349,10 @@ message: "Invalid Request", data: "JSON-RPC version must be 2.0", } - assert_nil @response[:id] + refute @response.key?(:id) end - it "returns nil for id on an Invalid Request error when the request id is explicitly null" do + it "omits the id on an Invalid Request error when the request id is explicitly null" do handle jsonrpc: "1.0", id: nil, method: "ping", params: {} assert_rpc_error expected_error: { @@ -358,10 +360,10 @@ message: "Invalid Request", data: "JSON-RPC version must be 2.0", } - assert_nil @response[:id] + refute @response.key?(:id) end - it "returns nil for id on an Invalid Request error when the id fails validation" do + it "omits the id on an Invalid Request error when the id fails validation" do handle jsonrpc: "1.0", id: "", method: "ping", params: {} assert_rpc_error expected_error: { @@ -369,7 +371,7 @@ message: "Invalid Request", data: "JSON-RPC version must be 2.0", } - assert_nil @response[:id] + refute @response.key?(:id) end # 5.1 Error object @@ -405,6 +407,7 @@ message: "Parse error", data: "Invalid JSON", } + refute @response.key?(:id) end it "returns an error with code set to -32600 when the request is not an array or a hash" do diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 1e1ebc7a..3f431296 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -171,7 +171,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, body["error"]["code"] assert_equal "Parse error: Invalid JSON", body["error"]["message"] end @@ -300,7 +300,7 @@ def string assert_equal 404, response[0] body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Session not found", body["error"]["message"] end @@ -388,7 +388,7 @@ def string assert_equal 400, response[0] body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_match(/single request object/i, body["error"]["message"]) end @@ -553,7 +553,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Missing session ID", body["error"]["message"] end @@ -711,7 +711,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"]["message"] end @@ -1618,7 +1618,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Method not allowed", body["error"]["message"] end @@ -1636,7 +1636,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"]["message"] end @@ -1831,7 +1831,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"] end @@ -2996,7 +2996,7 @@ def string body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_nil body["id"] + refute body.key?("id") assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, body["error"]["code"] assert_equal "Internal server error", body["error"]["message"] end @@ -6758,9 +6758,8 @@ def string parsed = JSON.parse(response[2][0]) - # The member is present and null, as JSON-RPC 2.0 requires when the id cannot be determined. - assert parsed.key?("id") - assert_nil parsed["id"] + # The member is left out: MCP allows no null id, where JSON-RPC 2.0 would set it to null. + refute parsed.key?("id") assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed.dig("error", "code") assert_empty @transport.instance_variable_get(:@listen_subscriptions) end