Merge pull request #371 from koic/use_json_rpc_envelope_for_transport_errors

Use JSON-RPC error envelope for `StreamableHTTPTransport` errors
This commit is contained in:
Koichi ITO 2026-06-13 20:32:17 +09:00 • committed by GitHub
commit 41dbe869be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
error: { message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
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:)
[400, { "Content-Type" => "application/json" }, [body.to_json]] 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