MCP stdio Session Debugger

Paste a captured MCP stdio session and find the framing errors: a log line on stdout that corrupts the stream, a request that was never answered, a reused id, and a handshake in the wrong order.

Paste below, or drop a file anywhere on this panel

Or drop a file anywhere on this panel. Nothing is uploaded: the analysis runs in this tab.

The answer appears here

Paste on the left and press Debug. Nothing leaves this tab.

Examples

Real input you can load into the tool above. Each one shows a different thing going wrong, because that is what the tool is for.

A log line on stdout

One banner in the middle of the stream desynchronises the reader for everything after it

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}
Server running on stdio
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}

A request nobody answered

The usual cause is a handler that threw inside an async function, rejecting a promise nobody awaited

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slow","arguments":{}}}

An id reused after being answered

MCP requires ids unique for the session, not merely unique among the requests still open

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}

Common mistakes

These are the ones that fail silently. The config is accepted, nothing raises an error, and the consequence arrives later.

  1. Logging to stdout

    stdout is the transport. A banner, a progress bar or a dependency's warning on import lands mid-frame, and most clients stay desynchronised for everything after it rather than recovering.

    Instead:Every log line to stderr. In Node that means `console.error`, and it includes anything printed by a package on import.

  2. Pretty-printing messages for readability

    The framing is one message per line. A multi-line JSON document is read as several broken frames, not as one nicely formatted message.

    Instead:Serialize compactly. Read the log through a formatter afterwards if you need it readable.

  3. Resetting the request id counter

    MCP requires ids unique for the whole session, not merely unique among the requests currently open. A reused id means a late response is matched against the wrong caller, which returns another request's answer as yours.

    Instead:One monotonically increasing counter for the session lifetime.

The stdio transport, and the one write that breaks it

stdio is newline-delimited JSON over the process's own stdout. That is a smaller contract than it sounds, and the commonest way to break it is a line of perfectly reasonable logging.

stdout is the wire

Not a place the server also writes to. Every byte on stdout is read as part of a message, so a startup banner, a progress bar, a leftover console.log or a dependency printing a deprecation warning on import all arrive in the middle of a frame. The client fails to parse it, and most implementations do not resynchronise afterwards: everything from that point is garbage. Route logs to stderr, which clients read separately and surface as server logs.

This is why it works in the inspector and fails in the client

A manual run only exercises the paths you took. The noisy line often lives in an error branch, a cache miss, or a dependency that only prints on a cold start. The first time a real client hits that path, a server that has worked for weeks stops.

One message per line, and no newlines inside one

A pretty-printed JSON message is a framing error, not a formatting preference: the reader takes the first line, fails to parse it, and the remaining lines arrive as further broken frames. Serialize compactly.

The handshake is three messages, not two

initialize from the client, its result from the server, then notifications/initialized from the client. Servers are entitled to refuse work until the third arrives, so a client that skips it gets an empty tool list from a server that is behaving correctly. Nothing else may be exchanged before it completes, apart from ping.

Request ids are unique for the whole session

Not merely unique among the requests currently in flight. Reusing an id after its response has arrived is still a violation, and the reason is a late or duplicated response being matched against the wrong caller. A counter that resets, or one that is per-connection in a server that reconnects, produces exactly this.

An unanswered request is usually an uncaught rejection

Every request gets exactly one response, success or error. A handler that throws inside an async function without a catch rejects a promise nobody awaited, so no response is ever written and the caller hangs until its timeout. Wrap the dispatch, not the individual handlers.

What this cannot see

The server's stderr, its exit code, and anything it did before the first byte reached stdout. Those three are where the cause usually is when initialize is never answered, and they are not in this log. It also assumes the paste is one session in order; interleaving two sessions produces false reports of reused ids. Everything here is read from the text in your browser, so nothing is uploaded and nothing is executed.