Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions lib/json_rpc_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,19 @@ def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &metho

def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
begin
request = JSON.parse(request_json, symbolize_names: true)
begin
request = JSON.parse(request_json, symbolize_names: true)
rescue JSON::ParserError, EncodingError
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8, a parse error as well.
# Rescued around the parse only, so an error raised while handling keeps its own classification.
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::PARSE_ERROR,
message: "Parse error",
data: "Invalid JSON",
}).to_json
end

response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
rescue JSON::ParserError
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::PARSE_ERROR,
message: "Parse error",
data: "Invalid JSON",
})
rescue StandardError
# Last-resort guard so an unexpected handling error returns a JSON-RPC error
# instead of escaping to the transport loop.
Expand Down
5 changes: 3 additions & 2 deletions lib/mcp/server/transports/stdio_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def send_request(method, params = nil)
while @open && (line = read_line($stdin))
begin
parsed = JSON.parse(line.strip, symbolize_names: true)
rescue JSON::ParserError => e
rescue JSON::ParserError, EncodingError => e
MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
raise
end
Expand Down Expand Up @@ -158,7 +158,8 @@ def read_line(io)
def parse_line(line)
parsed = JSON.parse(line, symbolize_names: true)
parsed.is_a?(Hash) ? parsed : nil
rescue JSON::ParserError
rescue JSON::ParserError, EncodingError
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8; `handle_json` answers the parse error.
nil
end

Expand Down
3 changes: 2 additions & 1 deletion lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1974,7 +1974,8 @@ def parse_request_body(body_string)
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
rescue JSON::ParserError, TypeError
# `symbolize_names` raises `EncodingError`, not `JSON::ParserError`, for a key that is not valid UTF-8.
rescue JSON::ParserError, TypeError, EncodingError
raise InvalidJsonError
end

Expand Down
20 changes: 20 additions & 0 deletions test/json_rpc_handler_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,26 @@
}
end

it "returns an error with the code set to -32700 when a key is not valid UTF-8" do
# `JSON.parse` with `symbolize_names` raises `EncodingError` rather than `JSON::ParserError` for such a key.
handle_json "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"add\",\"k\xFF\":1}".b

assert_rpc_error expected_error: {
code: -32700,
message: "Parse error",
data: "Invalid JSON",
}
end

it "returns an error with the code set to -32603 when the input is not a string" do
handle_json nil

assert_rpc_error expected_error: {
code: -32603,
message: "Internal error",
}
end

it "returns an error with code set to -32600 when the request is not an array or a hash" do
handle true

Expand Down
55 changes: 55 additions & 0 deletions test/mcp/server/transports/stdio_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,30 @@ class StdioTransportTest < ActiveSupport::TestCase
end
end

test "open answers a frame whose JSON key is not valid UTF-8 with -32700 and keeps reading" do
# `JSON.parse` with `symbolize_names` raises `EncodingError` for such a key, which ended the loop.
# The frames are read as bytes here, so the key reaches the parser whatever `String#strip` does.
frames = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"k\xFF\":1}\n" \
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}\n"
input = StringIO.new(frames.b)
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout

begin
$stdin = input
$stdout = output
@transport.open

responses = output.string.lines.map { |line| JSON.parse(line, symbolize_names: true) }
assert_equal(-32700, responses[0][:error][:code])
assert_equal({ jsonrpc: "2.0", id: 2, result: {} }, responses[1])
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end

test "rejects duplicate initialize on the same stdio session with -32600" do
first = {
jsonrpc: "2.0",
Expand Down Expand Up @@ -339,6 +363,37 @@ class StdioTransportTest < ActiveSupport::TestCase
end
end

test "send_request reports a client reply whose JSON key is not valid UTF-8 as a parse failure" do
reader, writer = IO.pipe

# Read as bytes, so the key reaches the parser whatever `String#strip` does.
reader.binmode
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
reported = []
original_reporter = MCP.configuration.exception_reporter

begin
MCP.configuration.exception_reporter = ->(e, context) { reported << [e.class, context] }
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)

writer.write("{\"jsonrpc\":\"2.0\",\"id\":\"x\",\"result\":{\"k\xFF\":1}}\n".b)
writer.flush

assert_raises(EncodingError) { @transport.send_request("test/method") }
assert_equal([[EncodingError, { error: "Failed to parse response" }]], reported)
ensure
MCP.configuration.exception_reporter = original_reporter
$stdin = original_stdin
$stdout = original_stdout
writer.close
reader.close
end
end

test "send_request does not double-report intentional raises via exception_reporter" do
reader, writer = IO.pipe
output = StringIO.new
Expand Down
29 changes: 29 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,24 @@ def string
assert_equal "Parse error: Invalid JSON", body["error"]["message"]
end

test "handles POST request whose JSON key is not valid UTF-8 as a parse error" do
# `JSON.parse` with `symbolize_names` raises `EncodingError` for such a key, which the rescue
# turned into a 500 with an exception report.
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json" },
"{\"jsonrpc\":\"2.0\",\"id\":\"init\",\"method\":\"initialize\",\"k\xFF\":1}".b,
)

response = @transport.handle_request(request)

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
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
init_request = create_rack_request(
"POST",
Expand Down Expand Up @@ -6068,6 +6086,17 @@ def string
assert_equal "ok", body.dig("result", "content", 0, "text")
end

test "modern POST answers a JSON key that is not valid UTF-8 with 400 and -32700" do
# The era sniff parses a sessionless modern body before any rescue, so the `EncodingError` the parser
# raises for such a key escaped the transport.
body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"k\xFF\":1}".b

response = @transport.handle_request(modern_rack_request(body))

assert_equal 400, response[0]
assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, JSON.parse(response[2][0]).dig("error", "code")
end

test "modern POST with an unsupported header version returns 400 with -32022 and the supported list" do
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}, version: "2027-01-01"),
Expand Down
Loading