Use JSON-RPC error envelope for StreamableHTTPTransport errors
## Motivation and Context PR #347 introduced a single JSON-RPC error envelope response for unsupported `MCP-Protocol-Version` headers, while the rest of the transport-level error responses (Accept, Content-Type, Invalid JSON, session management, method not allowed, internal server error) still returned plain JSON of the form `{ "error": "..." }`. The Python SDK (`src/mcp/server/streamable_http.py`) and TypeScript SDK (`packages/server/src/server/streamableHttp.ts`) consistently use a JSON-RPC error envelope for all transport-level errors. Unify the Ruby SDK with them. ## Behavior All transport-level error responses now return a JSON-RPC error envelope: ```json { "jsonrpc": "2.0", "id": null, "error": { "code": -32600, "message": "..." } } ``` Affected helpers in `lib/mcp/server/transports/streamable_http_transport.rb`: - `validate_content_type` (HTTP 415) - `not_acceptable_response` (HTTP 406) - `parse_request_body` error (HTTP 400, `PARSE_ERROR` `-32700`) - `method_not_allowed_response` (HTTP 405) - `missing_session_id_response` (HTTP 400) - `session_not_found_response` (HTTP 404) - `session_already_connected_response` (HTTP 409) - Internal server error fallback in `handle_post` (HTTP 500, `INTERNAL_ERROR` `-32603`) - `validate_protocol_version_header` (HTTP 400, dedup with shared helper) A new private helper `json_rpc_error_response(status:, code:, message:)` centralizes the envelope construction. The "Invalid JSON" message wording is updated to "Parse error: Invalid JSON" to match the JSON-RPC 2.0 `PARSE_ERROR` semantics. ## How Has This Been Tested? Updated all affected tests in `test/mcp/server/transports/streamable_http_transport_test.rb` to assert the new envelope shape (`body["error"]["message"]` instead of `body["error"]`). ## Breaking Changes Clients that parsed the previous plain `{"error": "..."}` shape will need to read `body["error"]["message"]` (or `body["error"]["code"]`). The HTTP status codes are unchanged, only the response body structure changed.
This commit is contained in:
parent
cf44475ce9
commit
c91702899d
2 changed files with 103 additions and 58 deletions
|
|
@ -389,7 +389,11 @@ module MCP
|
|||
end
|
||||
rescue StandardError => e
|
||||
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
||||
[500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 500,
|
||||
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
||||
message: "Internal server error",
|
||||
)
|
||||
end
|
||||
|
||||
def handle_get(request)
|
||||
|
|
@ -513,19 +517,19 @@ module MCP
|
|||
media_type = content_type&.split(";")&.first&.strip&.downcase
|
||||
return if media_type == "application/json"
|
||||
|
||||
[
|
||||
415,
|
||||
{ "Content-Type" => "application/json" },
|
||||
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
|
||||
]
|
||||
json_rpc_error_response(
|
||||
status: 415,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Unsupported Media Type: Content-Type must be application/json",
|
||||
)
|
||||
end
|
||||
|
||||
def not_acceptable_response(required_types)
|
||||
[
|
||||
406,
|
||||
{ "Content-Type" => "application/json" },
|
||||
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
|
||||
]
|
||||
json_rpc_error_response(
|
||||
status: 406,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}",
|
||||
)
|
||||
end
|
||||
|
||||
def parse_request_body(body_string)
|
||||
|
|
@ -535,7 +539,11 @@ module MCP
|
|||
end
|
||||
|
||||
def invalid_json_response
|
||||
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 400,
|
||||
code: JsonRpcHandler::ErrorCode::PARSE_ERROR,
|
||||
message: "Parse error: Invalid JSON",
|
||||
)
|
||||
end
|
||||
|
||||
def initialize_request?(body)
|
||||
|
|
@ -548,15 +556,16 @@ module MCP
|
|||
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
||||
|
||||
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
|
||||
body = {
|
||||
jsonrpc: "2.0",
|
||||
id: nil,
|
||||
error: {
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
||||
},
|
||||
}
|
||||
[400, { "Content-Type" => "application/json" }, [body.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 400,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
||||
)
|
||||
end
|
||||
|
||||
def json_rpc_error_response(status:, code:, message:)
|
||||
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
|
||||
[status, { "Content-Type" => "application/json" }, [body.to_json]]
|
||||
end
|
||||
|
||||
def notification?(body)
|
||||
|
|
@ -793,15 +802,27 @@ module MCP
|
|||
end
|
||||
|
||||
def method_not_allowed_response
|
||||
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 405,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Method not allowed",
|
||||
)
|
||||
end
|
||||
|
||||
def missing_session_id_response
|
||||
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 400,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Missing session ID",
|
||||
)
|
||||
end
|
||||
|
||||
def session_not_found_response
|
||||
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
||||
json_rpc_error_response(
|
||||
status: 404,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Session not found",
|
||||
)
|
||||
end
|
||||
|
||||
def already_initialized_response(request_id)
|
||||
|
|
@ -821,11 +842,11 @@ module MCP
|
|||
end
|
||||
|
||||
def session_already_connected_response
|
||||
[
|
||||
409,
|
||||
{ "Content-Type" => "application/json" },
|
||||
[{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
|
||||
]
|
||||
json_rpc_error_response(
|
||||
status: 409,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: "Conflict: Only one SSE stream is allowed per session",
|
||||
)
|
||||
end
|
||||
|
||||
def setup_sse_stream(session_id)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,10 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Invalid JSON", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, body["error"]["code"]
|
||||
assert_equal "Parse error: Invalid JSON", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST request with JSON array body returns 400" do
|
||||
|
|
@ -219,7 +222,10 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 404, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do
|
||||
|
|
@ -247,7 +253,7 @@ module MCP
|
|||
|
||||
assert_equal(404, duplicate_response[0])
|
||||
body = JSON.parse(duplicate_response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
assert_equal("Session not found", body["error"]["message"])
|
||||
|
||||
refute(transport.send(:session_exists?, session_id), "expired session must be evicted")
|
||||
ensure
|
||||
|
|
@ -451,7 +457,10 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "rejects POST request without session ID in stateful mode" do
|
||||
|
|
@ -465,7 +474,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 400, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "rejects notification without session ID in stateful mode" do
|
||||
|
|
@ -479,7 +488,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 400, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "rejects response without session ID in stateful mode" do
|
||||
|
|
@ -493,7 +502,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 400, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "allows POST request without session ID in stateless mode" do
|
||||
|
|
@ -537,7 +546,10 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["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
|
||||
|
||||
test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do
|
||||
|
|
@ -579,7 +591,7 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "handles POST request with invalid session ID" do
|
||||
|
|
@ -598,7 +610,7 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "handles DELETE request with valid session ID" do
|
||||
|
|
@ -639,7 +651,7 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST returns 404 after session is deleted" do
|
||||
|
|
@ -672,7 +684,7 @@ module MCP
|
|||
assert_equal 404, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "DELETE returns 404 after session is already deleted" do
|
||||
|
|
@ -702,7 +714,7 @@ module MCP
|
|||
assert_equal 404, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "handles DELETE request with missing session ID" do
|
||||
|
|
@ -717,7 +729,7 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "closes transport and cleans up session" do
|
||||
|
|
@ -1317,7 +1329,10 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Method not allowed", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||
assert_equal "Method not allowed", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST request without Content-Type returns 415" do
|
||||
|
|
@ -1332,7 +1347,10 @@ module MCP
|
|||
assert_equal 415, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["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
|
||||
|
||||
test "POST request with wrong Content-Type returns 415" do
|
||||
|
|
@ -1379,7 +1397,7 @@ module MCP
|
|||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
|
||||
body["error"]
|
||||
body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST request with Accept header missing text/event-stream returns 406" do
|
||||
|
|
@ -1398,7 +1416,7 @@ module MCP
|
|||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
|
||||
body["error"]
|
||||
body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST request with Accept header missing application/json returns 406" do
|
||||
|
|
@ -1417,7 +1435,7 @@ module MCP
|
|||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
|
||||
body["error"]
|
||||
body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST request with valid Accept header succeeds" do
|
||||
|
|
@ -1524,7 +1542,10 @@ module MCP
|
|||
assert_equal 406, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["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
|
||||
|
||||
test "GET request with Accept header missing text/event-stream returns 406" do
|
||||
|
|
@ -1550,7 +1571,7 @@ module MCP
|
|||
assert_equal 406, response[0]
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]
|
||||
assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "GET request with valid Accept header succeeds" do
|
||||
|
|
@ -1960,7 +1981,7 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Method not allowed", body["error"]
|
||||
assert_equal "Method not allowed", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "stateless mode silently responds with success to session DELETE when session ID is not present" do
|
||||
|
|
@ -2501,7 +2522,10 @@ module MCP
|
|||
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Internal server error", body["error"]
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
assert_nil body["id"]
|
||||
assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, body["error"]["code"]
|
||||
assert_equal "Internal server error", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "send_request raises error in stateless mode" do
|
||||
|
|
@ -3372,7 +3396,7 @@ module MCP
|
|||
assert_equal(404, response[0])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
assert_equal("Session not found", body["error"]["message"])
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
|
@ -3407,7 +3431,7 @@ module MCP
|
|||
assert_equal(404, response[0])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
assert_equal("Session not found", body["error"]["message"])
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
|
@ -3563,7 +3587,7 @@ module MCP
|
|||
assert_equal(404, response[0])
|
||||
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
assert_equal("Session not found", body["error"]["message"])
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
|
@ -3756,7 +3780,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 400, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Missing session ID", body["error"]
|
||||
assert_equal "Missing session ID", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "POST response with invalid session ID returns 404" do
|
||||
|
|
@ -3781,7 +3805,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 404, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal "Session not found", body["error"]
|
||||
assert_equal "Session not found", body["error"]["message"]
|
||||
end
|
||||
|
||||
test "handle_regular_request returns 404 for unknown session_id" do
|
||||
|
|
@ -3797,7 +3821,7 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal(404, response[0])
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal("Session not found", body["error"])
|
||||
assert_equal("Session not found", body["error"]["message"])
|
||||
end
|
||||
|
||||
test "session-scoped log notification is sent only to the originating session" do
|
||||
|
|
|
|||
Loading…
Reference in a new issue