Skip to content

Commit 93787f0

Browse files
committed
Answer an HTTP request whose reply cannot be generated with Internal error
## Motivation and Context A response can hold a string that is not valid UTF-8 when the request carried one and the SDK or a handler repeated it, for example a tool name in its `Tool not found` error, since the JSON parser passes such bytes in a string value through. Generating that response raised `JSON::GeneratorError`. On the Streamable HTTP legacy path the error escaped while the SSE body was being written, cutting the response off; in JSON response mode and on the 2026-07-28 path the transport's rescue answered 500 with the error reported, for a failure that belongs to the one request. `JsonRpcHandler.handle_json` now answers such a request with `-32603` Internal error carrying the request id, and the 2026-07-28 path does the same before it writes the response. The error carries the request id when the response has one. In a batch each item that cannot be generated is replaced on its own, so the other items keep their results. The failure is passed to the exception reporter, since the Internal error sent to the client no longer shows what went wrong. Over stdio a batch is now answered by `handle_json` as well, with the request id, ahead of the transport's own fallback, which answered it without one. ## How Has This Been Tested? `test/json_rpc_handler_test.rb` and `test/mcp/server/transports/streamable_http_transport_test.rb` (the legacy SSE path, JSON response mode, and the 2026-07-28 path, each with a tool whose reply cannot be generated). Against the previous library the handler test raises `JSON::GeneratorError`, the SSE body raises it while it is written, and the other two answer 500. The handler test for a batch in which one item cannot be generated fails against the previous library, which has no `on_generate_error:`. The transport tests check that the failure reaches the exception reporter. The stdio batch test expects the item's id accordingly. ## Breaking Changes None.
1 parent 0e59bb4 commit 93787f0

6 files changed

Lines changed: 164 additions & 8 deletions

File tree

‎lib/json_rpc_handler.rb‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &metho
5353
end
5454
end
5555

56-
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
56+
# `on_generate_error`, when given, is called with the `JSON::GeneratorError` of a response that cannot be generated,
57+
# before that response is replaced with Internal error.
58+
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, on_generate_error: nil, &method_finder)
5759
begin
5860
begin
5961
request = JSON.parse(request_json, symbolize_names: true)
@@ -77,7 +79,35 @@ def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTE
7779
})
7880
end
7981

80-
response&.to_json
82+
begin
83+
response&.to_json
84+
rescue JSON::GeneratorError => e
85+
on_generate_error&.call(e)
86+
encodable_response(response, id_validation_pattern: id_validation_pattern).to_json
87+
end
88+
end
89+
90+
# `response` with each part JSON cannot generate replaced with Internal error. A batch is replaced item by item,
91+
# so the other items keep their results.
92+
def encodable_response(response, id_validation_pattern: nil)
93+
return response.map { |item| encodable_response(item, id_validation_pattern: id_validation_pattern) } if response.is_a?(Array)
94+
95+
response.to_json
96+
response
97+
rescue JSON::GeneratorError
98+
internal_error_for(response, id_validation_pattern: id_validation_pattern)
99+
end
100+
101+
# The error answering a request whose response cannot be generated: a result or an error holding a string that
102+
# is not valid UTF-8, typically one the request carried and the SDK or a handler repeated, such as a tool name
103+
# in its Tool not found error. It carries the request id when the response has one, which was validated before
104+
# the method ran.
105+
def internal_error_for(response, id_validation_pattern: nil)
106+
id = response[:id] if response.is_a?(Hash)
107+
error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
108+
code: ErrorCode::INTERNAL_ERROR,
109+
message: "Internal error",
110+
})
81111
end
82112

83113
def process_request(request, id_validation_pattern:, &method_finder)

‎lib/mcp/server.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@ def handle(request, session: nil, auth_info: nil)
303303
# for the current request, as in `handle`.
304304
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
305305
def handle_json(request, session: nil, auth_info: nil)
306-
JsonRpcHandler.handle_json(request, id_validation_pattern: nil) do |method, request_id|
306+
on_generate_error = ->(error) { report_exception(error, { error: "Failed to generate response" }) }
307+
JsonRpcHandler.handle_json(request, id_validation_pattern: nil, on_generate_error: on_generate_error) do |method, request_id|
307308
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
308309
end
309310
end

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -931,16 +931,26 @@ def handle_modern(request, header_version, body_string: nil)
931931
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
932932
return handle_accepted if response.nil?
933933

934+
# A response that cannot be generated answers its request with Internal error, as `JsonRpcHandler.handle_json`
935+
# does on the legacy paths, instead of failing the whole exchange with a 500.
936+
response_json = begin
937+
response.to_json
938+
rescue JSON::GeneratorError => e
939+
MCP.configuration.exception_reporter.call(e, { error: "Failed to generate response" })
940+
response = JsonRpcHandler.encodable_response(response)
941+
response.to_json
942+
end
943+
934944
# Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
935945
# the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
936946
# Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
937947
# (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
938948
# Live streaming can follow without changing the wire shape.
939949
if notifications.empty?
940-
[modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
950+
[modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
941951
else
942-
frames = notifications + [response]
943-
sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
952+
frames = notifications.map(&:to_json) + [response_json]
953+
sse_body = frames.map { |frame| "event: message\ndata: #{frame}\n\n" }.join
944954
[200, SSE_HEADERS.dup, [sse_body]]
945955
end
946956
rescue StandardError => e

‎test/json_rpc_handler_test.rb‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,38 @@
488488
}
489489
end
490490

491+
it "returns an error with the code set to -32603 and the request id when the result cannot be generated" do
492+
register("echo") { |_params| { text: "\xFF".dup.force_encoding(Encoding::UTF_8) } }
493+
494+
handle_json({ jsonrpc: "2.0", id: 1, method: "echo" }.to_json)
495+
496+
assert_equal(1, @response[:id])
497+
assert_rpc_error expected_error: {
498+
code: -32603,
499+
message: "Internal error",
500+
}
501+
end
502+
503+
it "replaces only the batch item whose result cannot be generated and reports the error" do
504+
register("echo") { |_params| { text: "\xFF".dup.force_encoding(Encoding::UTF_8) } }
505+
register("ping") { |_params| {} }
506+
errors = []
507+
508+
response_json = JsonRpcHandler.handle_json(
509+
[{ jsonrpc: "2.0", id: 1, method: "echo" }, { jsonrpc: "2.0", id: 2, method: "ping" }].to_json,
510+
on_generate_error: ->(error) { errors << error.class },
511+
) { |method_name, _request_id| @registry[method_name] }
512+
513+
assert_equal(
514+
[
515+
{ jsonrpc: "2.0", id: 1, error: { code: -32603, message: "Internal error" } },
516+
{ jsonrpc: "2.0", id: 2, result: {} },
517+
],
518+
JSON.parse(response_json, symbolize_names: true),
519+
)
520+
assert_equal([JSON::GeneratorError], errors)
521+
end
522+
491523
it "returns nil when the method does not exist and the id is nil" do
492524
handle jsonrpc: "2.0", method: "add", params: { a: 1, b: 2 }
493525

‎test/mcp/server/transports/stdio_transport_test.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ class StdioTransportTest < ActiveSupport::TestCase
735735
assert_equal([false, true, true, false], responses.map { |response| response.key?(:error) })
736736
end
737737

738-
test "open answers a batch whose reply cannot be encoded with Internal error without an id" do
738+
test "open answers a batch whose reply cannot be encoded with Internal error and the item's id" do
739739
server = Server.new(name: "test_server")
740740
server.define_tool(name: "invalid_text") do
741741
Tool::Response.new([{ type: "text", text: "\xFF".dup.force_encoding(Encoding::UTF_8) }])
@@ -750,7 +750,7 @@ class StdioTransportTest < ActiveSupport::TestCase
750750
])
751751

752752
assert_equal(
753-
{ jsonrpc: "2.0", error: { code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, message: "Internal error" } },
753+
{ jsonrpc: "2.0", id: 2, error: { code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, message: "Internal error" } },
754754
responses[1],
755755
)
756756
assert_equal({ jsonrpc: "2.0", id: 3, result: {} }, responses[2])

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2792,6 +2792,68 @@ def string
27922792
assert_equal "456", body["id"]
27932793
end
27942794

2795+
test "a reply that cannot be generated is answered with Internal error on the legacy SSE path" do
2796+
reported = []
2797+
configuration = MCP::Configuration.new
2798+
configuration.exception_reporter = ->(e, context) { reported << [e.class, context] }
2799+
@server = Server.new(name: "test_server", configuration: configuration)
2800+
@server.define_tool(name: "invalid_text") do
2801+
Tool::Response.new([{ type: "text", text: "\xFF".dup.force_encoding(Encoding::UTF_8) }])
2802+
end
2803+
@transport.close
2804+
@transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: nil)
2805+
session_id = initialize_test_session
2806+
2807+
response = @transport.handle_request(create_rack_request(
2808+
"POST",
2809+
"/",
2810+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
2811+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "invalid_text", arguments: {} } }.to_json,
2812+
))
2813+
io = StringIO.new
2814+
response[2].call(io)
2815+
2816+
assert_equal 200, response[0]
2817+
assert_equal(
2818+
{ "jsonrpc" => "2.0", "id" => 2, "error" => { "code" => -32603, "message" => "Internal error" } },
2819+
JSON.parse(io.string[/^data: (.+)$/, 1]),
2820+
)
2821+
assert_equal([[JSON::GeneratorError, { error: "Failed to generate response" }]], reported)
2822+
end
2823+
2824+
test "a reply that cannot be generated is answered with Internal error in JSON response mode" do
2825+
reported = []
2826+
configuration = MCP::Configuration.new
2827+
configuration.exception_reporter = ->(e, context) { reported << [e.class, context] }
2828+
server = Server.new(name: "test", configuration: configuration)
2829+
server.define_tool(name: "invalid_text") do
2830+
Tool::Response.new([{ type: "text", text: "\xFF".dup.force_encoding(Encoding::UTF_8) }])
2831+
end
2832+
transport = StreamableHTTPTransport.new(server, enable_json_response: true)
2833+
session_id = transport.handle_request(create_rack_request(
2834+
"POST",
2835+
"/",
2836+
{ "CONTENT_TYPE" => "application/json" },
2837+
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
2838+
))[1]["mcp-session-id"]
2839+
2840+
response = transport.handle_request(create_rack_request(
2841+
"POST",
2842+
"/",
2843+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
2844+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "invalid_text", arguments: {} } }.to_json,
2845+
))
2846+
2847+
assert_equal 200, response[0]
2848+
assert_equal(
2849+
{ "jsonrpc" => "2.0", "id" => 2, "error" => { "code" => -32603, "message" => "Internal error" } },
2850+
JSON.parse(response[2][0]),
2851+
)
2852+
assert_equal([[JSON::GeneratorError, { error: "Failed to generate response" }]], reported)
2853+
ensure
2854+
transport.close
2855+
end
2856+
27952857
test "JSON response mode returns application/json for POST requests" do
27962858
server = Server.new(name: "test", tools: [], prompts: [], resources: [])
27972859
transport = StreamableHTTPTransport.new(server, enable_json_response: true)
@@ -6280,6 +6342,27 @@ def string
62806342
assert_equal 405, @transport.handle_request(delete_request)[0]
62816343
end
62826344

6345+
test "modern POST answers a reply that cannot be generated with Internal error" do
6346+
reported = []
6347+
original_reporter = MCP.configuration.exception_reporter
6348+
MCP.configuration.exception_reporter = ->(e, context) { reported << [e.class, context] }
6349+
@server.define_tool(name: "invalid_text") do
6350+
Tool::Response.new([{ type: "text", text: "\xFF".dup.force_encoding(Encoding::UTF_8) }])
6351+
end
6352+
6353+
response = @transport.handle_request(modern_rack_request(
6354+
modern_body("tools/call", { name: "invalid_text", arguments: {} }),
6355+
))
6356+
6357+
assert_equal 200, response[0]
6358+
body = JSON.parse(response[2][0])
6359+
assert_equal 1, body["id"]
6360+
assert_equal({ "code" => -32603, "message" => "Internal error" }, body["error"])
6361+
assert_equal([[JSON::GeneratorError, { error: "Failed to generate response" }]], reported)
6362+
ensure
6363+
MCP.configuration.exception_reporter = original_reporter
6364+
end
6365+
62836366
test "modern POST maps an unknown method to 404 with -32601" do
62846367
response = @transport.handle_request(modern_rack_request(modern_body("no/such_method", {})))
62856368

0 commit comments

Comments
 (0)