fix(server): survive malformed JSON-RPC frames instead of exiting - #60
Merged
Merged
Conversation
A single malformed frame from any client could end the MCP session, which is a denial-of-service primitive against a stdio server (issue #54). Two independent defects: - Invalid UTF-8 bytes made `utf8.decoder` raise a FormatException on the *stream*. The per-line try/catch never saw it and `listen()` had no `onError`, so the exception went unhandled and terminated the process with exit code 255 — the `eof` the conformance harness reported. - Truncated or non-JSON frames were caught, but the reply went through `_sendError(null, ...)`, which returns early on a null id. The parse error was therefore silently dropped and the client saw a hung server. Decode with `allowMalformed: true` so bad bytes degrade into an ordinary parse error, add `onError`/`cancelOnError: false` to keep the read loop alive, and introduce `_sendProtocolError` for the null-id replies that JSON-RPC 2.0 requires. Frames that are valid JSON but not request objects now answer -32600 rather than being ignored. `run()` also awaits stdin closing now. It previously returned as soon as the listener was attached, so `runServer` released the single-instance lock while the server was still serving.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #54.
Summary
A single malformed frame from any client ended the MCP session. Reproduced against
mainwith a stdio probe; two independent defects were responsible.1. Invalid UTF-8 killed the process.
stdin.transform(utf8.decoder)raisesFormatExceptionon the stream, not inside the per-line callback, andlisten()had noonError. The exception was unhandled and terminated the isolate:That is the
unresponsive after malformed input (eof)verdict in the report.2. Parse errors were silently dropped. Truncated JSON was caught, but the reply went through
_sendError(null, -32700, ...), and_sendErroropens withif (id == null) return;. The guard is correct for notifications and wrong for parse errors — JSON-RPC 2.0 requires them to be answered with a null id. The client saw silence and concluded the server was hung.Changes
Utf8Decoder(allowMalformed: true)so bad bytes become U+FFFD and degrade into an ordinary parse error instead of a stream failure.onErrorandcancelOnError: falseto the stdin subscription so no stream-level error can end the session._sendProtocolError(code, message)for the null-id replies the spec requires, leaving_sendError's notification behaviour untouched.-32600 Invalid Requestfor frames that are valid JSON but not request objects (previously ignored with no reply).run()now awaits stdin closing. It used to return as soon as the listener was attached, sorunServerreleased the single-instance lock while the server was still live.Test plan
test/server_malformed_frame_test.dartdrives a real server subprocess through truncated JSON, non-JSON text, invalid UTF-8 bytes, and a JSON array, asserting each is answered and that a followingtools/liststill succeeds. A fifth test pins that notifications stay unanswered.main, 5/5 pass with the fix. (The notification test passes on both, as intended.)flutter analyze lib/src/cli/server.dart test/server_malformed_frame_test.dart— no issues.🤖 Generated with Claude Code