Skip to content

fix(server): survive malformed JSON-RPC frames instead of exiting - #60

Merged
charliewwdev merged 1 commit into
mainfrom
fix/jsonrpc-malformed-frame-dos
Sep 1, 2026
Merged

charliewwdev merged 1 commit into
mainfrom
fix/jsonrpc-malformed-frame-dos

Conversation

@charliewwdev

Copy link
Copy Markdown
Member

Fixes #54.

Summary

A single malformed frame from any client ended the MCP session. Reproduced against main with a stdio probe; two independent defects were responsible.

1. Invalid UTF-8 killed the process. stdin.transform(utf8.decoder) raises FormatException on the stream, not inside the per-line callback, and listen() had no onError. The exception was unhandled and terminated the isolate:

[ERR] Unhandled exception:
[ERR] FormatException: Invalid UTF-8 byte (at offset 0)
### exited=255

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 _sendError opens with if (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

  • Decode with Utf8Decoder(allowMalformed: true) so bad bytes become U+FFFD and degrade into an ordinary parse error instead of a stream failure.
  • Add onError and cancelOnError: false to the stdin subscription so no stream-level error can end the session.
  • Add _sendProtocolError(code, message) for the null-id replies the spec requires, leaving _sendError's notification behaviour untouched.
  • Answer -32600 Invalid Request for 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, so runServer released the single-instance lock while the server was still live.

Test plan

  • New test/server_malformed_frame_test.dart drives 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 following tools/list still succeeds. A fifth test pins that notifications stay unanswered.
  • Verified the tests are not vacuous: 4 of 5 fail on 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.
  • Manual probe: 6 hostile frames in a row, server answers each and continues serving.
--- MALFORMED json (truncated)
[OUT] {"jsonrpc":"2.0","id":null,"error":{"code":-32700,...}}
--- INVALID UTF-8 bytes
[OUT] {"jsonrpc":"2.0","id":null,"error":{"code":-32700,...}}
--- JSON array (not an object)
[OUT] {"jsonrpc":"2.0","id":null,"error":{"code":-32600,...}}
--- notification (no id, must be silent)
--- valid tools/list
[OUT] {"jsonrpc":"2.0","id":9,"result":{"tools":[...]}}
### exited=None  alive=True

🤖 Generated with Claude Code

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.
@charliewwdev
charliewwdev merged commit fea1b27 into main Sep 1, 2026
4 of 5 checks passed
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.

Server exits on a malformed JSON-RPC frame (denial-of-service)

1 participant