Skip to content

Commit 37270d6

Browse files
authored
Merge pull request #629 from koic/answer_an_http_request_whose_reply_cannot_be_encoded
Answer an HTTP request whose reply cannot be generated with Internal error
2 parents 648c6ac + 93787f0 commit 37270d6

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)