Skip to content

Treat a JSON key that is not valid UTF-8 as a parse error - #624

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:treat_a_json_key_that_is_not_valid_utf_8_as_a_parse_error
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:treat_a_json_key_that_is_not_valid_utf_8_as_a_parse_error

Conversation

@koic

@koic koic commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Motivation and Context

JSON.parse with symbolize_names: true raises EncodingError, not JSON::ParserError, for an object key that is not valid UTF-8, since such a key cannot become a Symbol; the same bytes in a value are accepted. StreamableHTTPTransport#parse_request_body, StdioTransport#parse_line and JsonRpcHandler.handle_json rescued only the parser's own error. A sessionless modern POST is parsed from handle_request to tell the eras apart, before any rescue, so such a body raised out of the transport into the web server, whose error page then answered; a legacy POST answered 500 with the error reported, handle_json answered -32603, and over stdio the error raised out of open and ended the read loop. JSON text must be UTF-8 (RFC 8259, section 8.1), and the Python server refuses such a body as invalid JSON.

The three now answer it as the parse error it is: -32700, over HTTP with 400 as for any other body that is not JSON. handle_json rescues the parse error around the parse only, so an error raised while handling keeps its own classification and a non-string input still answers -32603.

The loop of #send_request that reads the client's reply rescues it the same way, so such a reply is reported as a parse failure before it fails the request, as an unparsable reply is. Over stdio on Ruby 3.2 and later, a frame holding such a byte is stopped earlier, by String#strip in the read loop; stripping frames as bytes is a separate change, and the stdio tests read their frames as bytes so that they cover the parse step alone.

How Has This Been Tested?

test/json_rpc_handler_test.rb, test/mcp/server/transports/streamable_http_transport_test.rb (a sessionless modern POST and a legacy initialize) and test/mcp/server/transports/stdio_transport_test.rb. Against the previous library the handler test gets -32603, the modern test raises EncodingError, the legacy test gets a 500 and the stdio test raises out of open. A client reply with such a key is reported as a parse failure; against the previous library it raises without the report.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

`JSON.parse` with `symbolize_names: true` raises `EncodingError`, not `JSON::ParserError`, for an object key
that is not valid UTF-8, since such a key cannot become a Symbol; the same bytes in a value are accepted.
`StreamableHTTPTransport#parse_request_body`, `StdioTransport#parse_line` and `JsonRpcHandler.handle_json`
rescued only the parser's own error. A sessionless modern POST is parsed from `handle_request` to tell
the eras apart, before any rescue, so such a body raised out of the transport into the web server, whose error
page then answered; a legacy POST answered 500 with the error reported, `handle_json` answered `-32603`, and over
stdio the error raised out of `open` and ended the read loop. JSON text must be UTF-8 (RFC 8259, section 8.1),
and the Python server refuses such a body as invalid JSON.

The three now answer it as the parse error it is: `-32700`, over HTTP with 400 as for any other body that is
not JSON. `handle_json` rescues the parse error around the parse only, so an error raised while handling keeps
its own classification and a non-string input still answers `-32603`.

The loop of `#send_request` that reads the client's reply rescues it the same way, so such a reply is reported
as a parse failure before it fails the request, as an unparsable reply is. Over stdio on Ruby 3.2 and later,
a frame holding such a byte is stopped earlier, by `String#strip` in the read loop; stripping frames as bytes is
a separate change, and the stdio tests read their frames as bytes so that they cover the parse step alone.

## How Has This Been Tested?

`test/json_rpc_handler_test.rb`, `test/mcp/server/transports/streamable_http_transport_test.rb` (a sessionless
modern POST and a legacy `initialize`) and `test/mcp/server/transports/stdio_transport_test.rb`. Against
the previous library the handler test gets `-32603`, the modern test raises `EncodingError`, the legacy test
gets a 500 and the stdio test raises out of `open`.
A client reply with such a key is reported as a parse failure; against the previous library it raises without
the report.

## Breaking Changes

None.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants