# Can MCP stdio backpressure freeze your agent?

A large MCP tool result can freeze an agent process even when every line of your protocol implementation is technically valid. The failure comes from treating stdio as an infinitely fast message bus. It is a bounded byte stream between two processes, and both directions can stall when either side quits reading.

That matters more for agent tools than for ordinary command-line programs. A tool can produce a giant search result, an encoded file, a full API payload, or a verbose command transcript. The agent may then tokenize it, summarize it, wait for an approval, or simply decide it has enough context. If the host stops draining the server's stdout during any of that work, the server can block mid-response. Once blocked, it may not read a cancellation notification or a later request from stdin.

The fix is not one setting. You need a response contract that keeps results small, a transport loop that drains stdout independently of agent work, and a cancellation path that reaches the work doing the producing. Test all three with deliberately hostile payloads.

## A blocked pipe can look like an agent bug

A blocked stdio pipe produces symptoms that send teams in the wrong direction. The agent appears frozen after a tool call. The server still has a live process. CPU use may be low. A timeout fires, but a retry also hangs. Someone blames the model runtime, the MCP SDK, or a lock in the tool implementation.

Often the tool has already done its work. It is stuck in `write()` while trying to deliver a response that the host is no longer draining. Pipe capacity is limited and platform dependent. Node's child process documentation says exactly what shell programmers have known for decades: when a subprocess writes more than a pipe can hold and the parent does not capture it, the subprocess blocks until the pipe accepts more bytes.

There are two distinct queues in this incident, and mixing them up causes bad fixes.

- The operating system pipe holds raw stdout bytes between the MCP server and its host.
- The host's application queue holds parsed JSON-RPC messages waiting for agent code, UI code, logging, or context assembly.

Increasing an application queue does nothing if nobody is reading the pipe. Increasing a pipe or stream buffer can postpone a block, but it gives the server more room to create a response the agent should never have received. Result budgeting decides what belongs in the conversation. Backpressure handling decides what happens when one side is slower anyway. They solve different problems.

The official MCP debugging guidance also gives you one easy diagnostic boundary: local stdio servers must keep logs off stdout. Put diagnostics on stderr. If stdout contains a banner, a stack trace, or a progress line that is not protocol data, you have a framing failure before you even reach the large-result problem.

## Stdout must stay readable while work continues

The host owns the most important rule: attach and keep the stdout reader running for the life of the server process. Do not tie it to a promise that waits for the agent to finish considering a tool result. Do not pause it during an approval dialog. Do not wait for a renderer, database write, or model request before accepting the next bytes.

Use a transport loop with narrow responsibilities:

1. Read stdout as bytes whenever the operating system supplies them.
2. Feed those bytes to the protocol framer.
3. Reject malformed or oversized frames as transport faults.
4. Hand complete messages to a bounded dispatcher that is separate from the reader.
5. Keep draining or terminate the child deliberately when the dispatcher cannot accept more work.

The important word is "separate." A reader that invokes expensive message handling inline will eventually become a reader that stops reading. JSON parsing itself can hurt when a message is enormous, but the usual mistake comes earlier: the reader waits on a callback that is doing unrelated work.

A host should record at least these values per server session: bytes received on stdout, largest complete frame, parse failures, time spent waiting for dispatch capacity, cancellation requests sent, and process exits. Those numbers settle arguments quickly. If stdout bytes stop increasing halfway through a result and the server remains alive, suspect the server's write side. If bytes keep arriving but complete-message dispatch stops, suspect the host queue or its consumer.

Do not make stdout flow control depend on whether a tool response is useful to the model. The reader must receive the entire protocol message before it can safely discard, report, or route it. A host that decides midway through a frame that the message is too big and then stops reading has created the deadlock itself.

## A result limit needs two boundaries

Set a wire limit and a content limit. A single character count does not protect the process because JSON escaping, base64 encoding, and surrounding response structure change the byte count on stdout.

The wire limit is the maximum byte size of one complete serialized JSON-RPC message. Enforce it in the framer before parsing arbitrary JSON. It protects memory and parser time in the host. The content limit is the maximum useful amount a tool returns inside `content` or `structuredContent`. Enforce it in the tool handler before serializing a result. It protects agent context and keeps the response meaningful.

Neither limit belongs in a tool description alone. A model will occasionally request an unbounded search, a recursive listing, or a full document. The server must handle that request predictably.

A practical tool response says what it omitted and how the agent can continue. This is better than silently slicing a string, because silent truncation looks like complete evidence.

```json
{
  "jsonrpc": "2.0",
  "id": 41,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Returned 50 of 4,382 matching records. Results are sorted by updated time. Use cursor \"eyJvZmZzZXQiOjUwfQ\" to continue, or add a narrower path or query."
      }
    ],
    "structuredContent": {
      "items": [
        {"path": "src/auth.ts", "line": 18, "summary": "reads token from environment"}
      ],
      "nextCursor": "eyJvZmZzZXQiOjUwfQ",
      "truncated": true,
      "totalEstimate": 4382
    }
  }
}
```

The text gives the model a plain explanation. The structured content gives a client a stable continuation token and a machine-readable truncation signal. Do not send an invented total if counting all records is expensive or impossible. Say `truncated: true` and omit the count. Fake precision wastes more time than an honest incomplete result.

Avoid the popular recommendation to "just return a file path." It works only when the host and server share a filesystem, the path is authorized, the agent can read it, and the artifact remains present. In remote or sandboxed setups, it is a broken promise. A reference can be useful, but it needs a corresponding read or export operation with its own bounds.

## Return decisions, not raw exhaust

Most oversized responses come from tools whose output model was copied from a command line. `grep -R`, `git diff`, a cloud list API, and a database query all make sense for a human at a terminal. They do not become good agent interfaces merely because you wrap them in JSON.

An agent usually needs enough evidence to choose its next action. Give it a bounded set of matches, the relevant fields, and a way to narrow the request. Save the complete artifact for an explicit export or retrieval path, where the caller opts into paging or a constrained range.

For repository search, return file paths, line ranges, short excerpts, and the query used. Do not return every matching line in a monorepo. For an HTTP client, return status, selected headers, a bounded body preview, and a response handle if your product can safely retain one. Do not base64 an arbitrary download into `content`. For SSH, return a capped tail of stdout and stderr plus the exit status. A command that prints a huge generated file has told you it produced a huge generated file. The agent rarely needs every byte in its immediate context.

Put limits near the source of expansion. A server that calls a remote API should pass pagination and field selectors to the remote API. A server that runs a process should cap the subprocess capture while continuing to drain both stdout and stderr. A server that searches files should stop after its result budget, not collect every match then crop the final string.

This distinction matters because truncating after collection protects the MCP wire but does not protect the machine doing the work. A recursive command can still consume memory, disk, and CPU before the response layer drops its output.

Sallyport is useful here because it keeps HTTP and SSH credentials out of the agent while actions run through its local gateway. That boundary does not make an unbounded API body or shell transcript safe to return, so tool authors still need explicit output budgets at the action boundary.

## Reproduce the stall before you claim it is fixed

You cannot test this failure by calling a tool and checking whether a large result eventually appears. Build a harness that intentionally stops draining stdout, then prove the server enters the blocked state. After that, prove the normal host never behaves that way.

This small Node fixture writes a valid JSON-RPC response with a payload large enough to exceed ordinary pipe capacity when its parent ignores stdout. It receives one request line from stdin, then writes the response in chunks. The `drain` wait is the evidence: it tells you when the runtime has applied backpressure to the server's stdout writer.

```js
// oversized-server.mjs
import readline from "node:readline";
import { once } from "node:events";

const rl = readline.createInterface({ input: process.stdin });

for await (const line of rl) {
  const request = JSON.parse(line);
  const text = "x".repeat(8 * 1024 * 1024);
  const response = JSON.stringify({
    jsonrpc: "2.0",
    id: request.id,
    result: { content: [{ type: "text", text }] }
  }) + "\n";

  for (let start = 0; start < response.length; start += 16 * 1024) {
    const chunk = response.slice(start, start + 16 * 1024);
    if (!process.stdout.write(chunk)) {
      process.stderr.write("stdout backpressure observed\n");
      await once(process.stdout, "drain");
    }
  }
}
```

Now spawn it with stdout piped and deliberately leave `child.stdout` unread. Continue reading stderr so you can see the backpressure marker. Send one request and wait briefly. The child should remain alive and should not finish writing. That is expected behavior, not a Node defect.

```js
// blocked-parent.mjs
import { spawn } from "node:child_process";

const child = spawn(process.execPath, ["oversized-server.mjs"], {
  stdio: ["pipe", "pipe", "pipe"]
});

child.stderr.setEncoding("utf8");
child.stderr.on("data", chunk => process.stderr.write(chunk));

child.stdin.write(JSON.stringify({
  jsonrpc: "2.0",
  id: 1,
  method: "tools/call",
  params: { name: "large", arguments: {} }
}) + "\n");

setTimeout(() => {
  console.error("child still running:", child.exitCode === null);
  child.kill("SIGTERM");
}, 1000);
```

Do not copy this parent pattern into production. Its point is to make the failure obvious. Replace the absent stdout consumer with your real framer and run the same fixture. The child should complete the response or the client should reject it under a stated limit, but it must not remain wedged because the parent ignored stdout.

Run the test with payloads that contain quotes, multibyte characters, and long unbroken strings. Those cases catch framers that count JavaScript characters instead of UTF-8 bytes, or assume every read chunk ends at a message boundary.

## Reject a giant frame without recreating the blockage

A frame-size check has one trap: a client that stops consuming the oversized frame will block the server just as surely as a client that never attached a reader. The client needs a recovery rule.

If your framing protocol gives you a clear end marker, continue reading until that marker while discarding the offending frame, then report a protocol violation and decide whether the session can continue. If the protocol encoding does not let you regain framing safely after the limit, terminate the server process, close its stdin, and start a fresh session. That sounds severe, but attempting to guess where a corrupted JSON message ends is worse.

MCP stdio uses JSON-RPC messages over a local byte stream. Your implementation must treat framing as transport code, not as a convenience `split("\n")` call after collecting unbounded text. Keep a byte counter while accumulating a candidate message. On each incoming chunk, either find complete frame boundaries and dispatch complete frames, or fail once the candidate exceeds your maximum.

Do not parse a gigantic message just to learn that it is gigantic. `JSON.parse` needs a complete in-memory string and may allocate more than its input size. The whole reason for a wire limit is to prevent that work.

If you control both ends and use newline-delimited JSON, require one JSON object per line, reject embedded literal newlines inside strings through normal JSON encoding, and write exactly one delimiter after each complete serialized object. The server must never write human diagnostics on stdout. MCP's own debugging documentation tells local servers to send logs to stderr because stdout is protocol territory.

A reader should also enforce a limit on the number of complete messages waiting for application handling. If that queue fills, do not pause stdout forever. You can reject new requests, cancel work where supported, or end the session. The correct overload action depends on the host, but indefinite unread stdout is never a safe default.

## Cancellation only works if it reaches the producer

A timeout in the agent UI is not cancellation. It only changes the UI's opinion of the request. The server may still be querying a database, downloading a response, running a process, and writing megabytes to stdout.

MCP defines `notifications/cancelled` for a previously issued request in the same direction. The notification includes the original request ID and can include a reason. The MCP schema says the receiver should stop associated processing, free resources, and treat the result as unused. It also warns that cancellation can race with completion. That is why a client must tolerate a late response and a server must tolerate a cancellation for a request it has already completed.

For a `tools/call`, the client sends a notification such as this through the same stdio session:

```json
{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": 41,
    "reason": "result exceeded the client budget"
  }
}
```

The server needs a map from request ID to active work. Each entry should contain an abort controller or language equivalent, a completion state, and any child process, HTTP request, or cursor it owns. When the cancellation notification arrives, abort the work, stop producing result content, clean up the map entry, and avoid sending a normal response if the response has not already begun.

This is where teams make a subtle mistake. They add an abort signal to the tool handler, but the handler awaits a child process whose stdout capture ignores that signal. Or they cancel an HTTP fetch but leave a transform still serializing a giant in-memory array. Cancellation is only real when it reaches every producer and every waiting operation.

Treat the point after the server starts writing a response as a separate state. A cancellation may arrive after bytes are already in the pipe. You cannot retract them. The host should keep draining enough to preserve session health, then ignore the late response associated with the cancelled ID. The server should stop additional work as soon as it sees cancellation, but it cannot promise that no late bytes exist.

## A cancellation test needs a second request

A good cancellation test proves recovery, not merely that a timer fired. Start a tool that produces output slowly enough for the client to cancel while it is active. Send cancellation. Then submit a small, unrelated request on the same MCP session. That second request must complete promptly.

The following test sequence catches the failures that matter:

1. Start a `tools/call` whose handler emits a large response in chunks or invokes a deliberately slow producer.
2. Wait until the harness observes enough stdout bytes to know the response has begun.
3. Send `notifications/cancelled` for that request ID.
4. Verify the producer exits or reports its abort path within your chosen deadline.
5. Send a small request with a new ID, such as a health tool or a bounded echo tool.

The final request is the test. It reveals whether the server is still blocked writing, whether its stdin reader got starved, whether a cancelled task held a global lock, or whether the host stopped draining stdout after deciding to cancel.

Also test cancellation before work begins, halfway through remote I/O, while a subprocess runs, and after the final response has started. These are different states. An implementation that handles one cleanly can fail another.

Do not assert that cancellation always prevents a response. The MCP specification allows races. Assert that the host remains correct if a response arrives after cancellation, and that server work stops when cancellation arrives in time to matter.

## Process output needs its own drain path

MCP servers often invoke command-line tools. That adds a second pipe pair inside the server: the server must consume the child process's stdout and stderr while it performs MCP work. If it only reads a child stream up to a limit and then stops, the child can block before it exits. The outer MCP server may then appear to ignore cancellation because it is waiting on a child that cannot make progress.

Capture a bounded preview, but continue draining after the preview limit. Mark the output as truncated and discard the remaining bytes. If the command supports its own result limit, pass it before launching the command. For example, ask a search utility for a maximum number of matches, ask a database for a limited page, or constrain a log command to a tail. Draining after the cap is the safety net, not the primary result strategy.

Keep stderr separate from stdout. A tool may write useful diagnostics to stderr while returning a normal exit status. Cap and drain both streams independently. Never merge arbitrary process output into MCP server stdout. The outer stdout channel has exactly one job: serialized MCP messages.

The same rule applies to SSH commands. A remote command can print until its channel blocks. Capture bounded data, keep consuming the remote streams until completion or cancellation, and make the returned summary honest about what you discarded. A full transcript belongs in a purpose-built artifact flow, not in a casual tool result.

## Put the failure in your release gate

Result limits and cancellation are easy to remove accidentally. A refactor can replace a streaming reader with `readFile`, change a paginated API call into an unbounded one, or move parsing into a UI callback. Keep the oversized-result fixture in the test suite.

Your release gate should cover a valid response just under the wire cap, a response just over it, an enormous single field, many small content blocks, malformed JSON, noisy stderr, an accidental stdout log, and cancellation during output. Run it against the real process spawn path, not only an in-memory transport. In-memory tests cannot show pipe backpressure.

Record the expected behavior in plain language: the client drains stdout continuously; it never parses above its configured frame limit; it reports a bounded-result failure; cancellation reaches active work; and another request can proceed after recovery. Engineers can change implementation details without weakening those guarantees.

If you only make one change, make it this one: separate the stdout reader from agent processing, then test it with a server that writes far more than any sensible tool should return. That test turns a vague "agent froze" report into a failure you can reproduce, measure, and keep out of the next release.
