Skip to content

Keep the stdio server running when a reply cannot be encoded - #623

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:keep_the_stdio_server_running_when_a_reply_cannot_be_encoded
Oct 20, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:keep_the_stdio_server_running_when_a_reply_cannot_be_encoded

Conversation

@koic

@koic koic commented Oct 19, 2026

Copy link
Copy Markdown
Member

Motivation and Context

StdioTransport#open generates each reply with JSON.generate and nothing rescued what that raised, so a reply holding a string that is not valid UTF-8 ended the read loop and the server process with JSON::GeneratorError. A request can carry such a string without being malformed: the json gem's 2.x releases, which Ruby 3.3, 3.4 and 4.0 ship as their default, decode an unpaired low surrogate escape such as \udfff into bytes that are not valid UTF-8, and a JavaScript client writes one whenever a string was cut in the middle of a surrogate pair. Any reply repeating it then fails: the Tool not found error data for such a tool name, a resource read echoing its URI, or a tool echoing an argument. The json gem's 3.x releases refuse the escape as a parse error instead. The Python server refuses it at parse time as well, and the TypeScript server writes it back as an escape, so neither stops on it.

The frame handling of #open now rescues a failure past the server's own error handling, reports it to the exception reporter, and answers the frame with -32603 Internal error, carrying the request id when it can be written back, as for any other request the server cannot complete; the loop goes on with the next frame. A notification still gets no response.

The parsed frame is reset for each frame, so a frame whose parse fails this way is not answered with the id of the frame before it.

How Has This Been Tested?

test/mcp/server/transports/stdio_transport_test.rb: a tool whose reply cannot be encoded, as a single request and in a batch, is answered with Internal error and the next request is served. Against the previous library both raise JSON::GeneratorError out of #open.
A frame whose parse raises past the parser's own error, after a request and after a notification, is answered without an id; against the previous library it raises out of #open.

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

`StdioTransport#open` generates each reply with `JSON.generate` and nothing rescued what that raised, so
a reply holding a string that is not valid UTF-8 ended the read loop and the server process with
`JSON::GeneratorError`. A request can carry such a string without being malformed: the json gem's 2.x
releases, which Ruby 3.3, 3.4 and 4.0 ship as their default, decode an unpaired low surrogate escape such
as `\udfff` into bytes that are not valid UTF-8, and a JavaScript client writes one whenever a string was cut
in the middle of a surrogate pair. Any reply repeating it then fails: the `Tool not found` error data for
such a tool name, a resource read echoing its URI, or a tool echoing an argument. The json gem's 3.x releases
refuse the escape as a parse error instead. The Python server refuses it at parse time as well, and
the TypeScript server writes it back as an escape, so neither stops on it.

The frame handling of `#open` now rescues a failure past the server's own error handling, reports it to
the exception reporter, and answers the frame with `-32603` Internal error, carrying the request id when it can
be written back, as for any other request the server cannot complete; the loop goes on with the next frame.
A notification still gets no response.

The parsed frame is reset for each frame, so a frame whose parse fails this way is not answered with
the id of the frame before it.

## How Has This Been Tested?

`test/mcp/server/transports/stdio_transport_test.rb`: a tool whose reply cannot be encoded, as a single request
and in a batch, is answered with Internal error and the next request is served. Against the previous library
both raise `JSON::GeneratorError` out of `#open`.
A frame whose parse raises past the parser's own error, after a request and after a notification, is answered
without an id; against the previous library it raises out of `#open`.

## Breaking Changes

None.
@koic
koic merged commit c8e8b62 into modelcontextprotocol:main Oct 20, 2026
11 checks passed
@koic
koic deleted the keep_the_stdio_server_running_when_a_reply_cannot_be_encoded branch October 20, 2026 17:29
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