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:
Koichi ITO 2026-05-14 01:28:17 +09:00
commit c91702899d
2 changed files with 103 additions and 58 deletions

View file

@ -389,7 +389,11 @@ module MCP
end end
rescue StandardError => e rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { request: body_string }) 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 end
def handle_get(request) def handle_get(request)
@ -513,19 +517,19 @@ module MCP
media_type = content_type&.split(";")&.first&.strip&.downcase media_type = content_type&.split(";")&.first&.strip&.downcase
return if media_type == "application/json" return if media_type == "application/json"
[ json_rpc_error_response(
415, status: 415,
{ "Content-Type" => "application/json" }, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json], message: "Unsupported Media Type: Content-Type must be application/json",
] )
end end
def not_acceptable_response(required_types) def not_acceptable_response(required_types)
[ json_rpc_error_response(
406, status: 406,
{ "Content-Type" => "application/json" }, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json], message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}",
] )
end end
def parse_request_body(body_string) def parse_request_body(body_string)
@ -535,7 +539,11 @@ module MCP
end end
def invalid_json_response 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 end
def initialize_request?(body) def initialize_request?(body)
@ -548,15 +556,16 @@ module MCP
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ") supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
body = { json_rpc_error_response(
jsonrpc: "2.0", status: 400,
id: nil,
error: {
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}", message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
}, )
} end
[400, { "Content-Type" => "application/json" }, [body.to_json]]
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 end
def notification?(body) def notification?(body)
@ -793,15 +802,27 @@ module MCP
end end
def method_not_allowed_response 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 end
def missing_session_id_response 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 end
def session_not_found_response 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 end
def already_initialized_response(request_id) def already_initialized_response(request_id)
@ -821,11 +842,11 @@ module MCP
end end
def session_already_connected_response def session_already_connected_response
[ json_rpc_error_response(
409, status: 409,
{ "Content-Type" => "application/json" }, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
[{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json], message: "Conflict: Only one SSE stream is allowed per session",
] )
end end
def setup_sse_stream(session_id) def setup_sse_stream(session_id)

View file

@ -93,7 +93,10 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) 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 end
test "POST request with JSON array body returns 400" do test "POST request with JSON array body returns 400" do
@ -219,7 +222,10 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 404, response[0] assert_equal 404, response[0]
body = JSON.parse(response[2][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 end
test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do 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]) assert_equal(404, duplicate_response[0])
body = JSON.parse(duplicate_response[2][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") refute(transport.send(:session_exists?, session_id), "expired session must be evicted")
ensure ensure
@ -451,7 +457,10 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) 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 end
test "rejects POST request without session ID in stateful mode" do test "rejects POST request without session ID in stateful mode" do
@ -465,7 +474,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Missing session ID", body["error"] assert_equal "Missing session ID", body["error"]["message"]
end end
test "rejects notification without session ID in stateful mode" do test "rejects notification without session ID in stateful mode" do
@ -479,7 +488,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Missing session ID", body["error"] assert_equal "Missing session ID", body["error"]["message"]
end end
test "rejects response without session ID in stateful mode" do test "rejects response without session ID in stateful mode" do
@ -493,7 +502,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Missing session ID", body["error"] assert_equal "Missing session ID", body["error"]["message"]
end end
test "allows POST request without session ID in stateless mode" do 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]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) 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 end
test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do 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]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "handles POST request with invalid session ID" do test "handles POST request with invalid session ID" do
@ -598,7 +610,7 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "handles DELETE request with valid session ID" do test "handles DELETE request with valid session ID" do
@ -639,7 +651,7 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "POST returns 404 after session is deleted" do test "POST returns 404 after session is deleted" do
@ -672,7 +684,7 @@ module MCP
assert_equal 404, response[0] assert_equal 404, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "DELETE returns 404 after session is already deleted" do test "DELETE returns 404 after session is already deleted" do
@ -702,7 +714,7 @@ module MCP
assert_equal 404, response[0] assert_equal 404, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "handles DELETE request with missing session ID" do test "handles DELETE request with missing session ID" do
@ -717,7 +729,7 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Missing session ID", body["error"] assert_equal "Missing session ID", body["error"]["message"]
end end
test "closes transport and cleans up session" do test "closes transport and cleans up session" do
@ -1317,7 +1329,10 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) 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 end
test "POST request without Content-Type returns 415" do test "POST request without Content-Type returns 415" do
@ -1332,7 +1347,10 @@ module MCP
assert_equal 415, response[0] assert_equal 415, response[0]
body = JSON.parse(response[2][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 end
test "POST request with wrong Content-Type returns 415" do test "POST request with wrong Content-Type returns 415" do
@ -1379,7 +1397,7 @@ module MCP
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
body["error"] body["error"]["message"]
end end
test "POST request with Accept header missing text/event-stream returns 406" do 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]) body = JSON.parse(response[2][0])
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
body["error"] body["error"]["message"]
end end
test "POST request with Accept header missing application/json returns 406" do test "POST request with Accept header missing application/json returns 406" do
@ -1417,7 +1435,7 @@ module MCP
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
body["error"] body["error"]["message"]
end end
test "POST request with valid Accept header succeeds" do test "POST request with valid Accept header succeeds" do
@ -1524,7 +1542,10 @@ module MCP
assert_equal 406, response[0] assert_equal 406, response[0]
body = JSON.parse(response[2][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 end
test "GET request with Accept header missing text/event-stream returns 406" do test "GET request with Accept header missing text/event-stream returns 406" do
@ -1550,7 +1571,7 @@ module MCP
assert_equal 406, response[0] assert_equal 406, response[0]
body = JSON.parse(response[2][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 end
test "GET request with valid Accept header succeeds" do test "GET request with valid Accept header succeeds" do
@ -1960,7 +1981,7 @@ module MCP
assert_equal({ "Content-Type" => "application/json" }, response[1]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Method not allowed", body["error"] assert_equal "Method not allowed", body["error"]["message"]
end end
test "stateless mode silently responds with success to session DELETE when session ID is not present" do 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]) assert_equal({ "Content-Type" => "application/json" }, response[1])
body = JSON.parse(response[2][0]) 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 end
test "send_request raises error in stateless mode" do test "send_request raises error in stateless mode" do
@ -3372,7 +3396,7 @@ module MCP
assert_equal(404, response[0]) assert_equal(404, response[0])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal("Session not found", body["error"]) assert_equal("Session not found", body["error"]["message"])
ensure ensure
transport.close transport.close
end end
@ -3407,7 +3431,7 @@ module MCP
assert_equal(404, response[0]) assert_equal(404, response[0])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal("Session not found", body["error"]) assert_equal("Session not found", body["error"]["message"])
ensure ensure
transport.close transport.close
end end
@ -3563,7 +3587,7 @@ module MCP
assert_equal(404, response[0]) assert_equal(404, response[0])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal("Session not found", body["error"]) assert_equal("Session not found", body["error"]["message"])
ensure ensure
transport.close transport.close
end end
@ -3756,7 +3780,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Missing session ID", body["error"] assert_equal "Missing session ID", body["error"]["message"]
end end
test "POST response with invalid session ID returns 404" do test "POST response with invalid session ID returns 404" do
@ -3781,7 +3805,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 404, response[0] assert_equal 404, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "Session not found", body["error"] assert_equal "Session not found", body["error"]["message"]
end end
test "handle_regular_request returns 404 for unknown session_id" do test "handle_regular_request returns 404 for unknown session_id" do
@ -3797,7 +3821,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal(404, response[0]) assert_equal(404, response[0])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal("Session not found", body["error"]) assert_equal("Session not found", body["error"]["message"])
end end
test "session-scoped log notification is sent only to the originating session" do test "session-scoped log notification is sent only to the originating session" do