8 min read

How MCP stderr limits keep agent runs moving

Set MCP stderr limits that prevent noisy helpers from consuming memory, delaying approvals, stalling shutdown, and leaving incomplete action records.

How MCP stderr limits keep agent runs moving

A noisy helper can stop an agent run without breaking a single JSON-RPC message. It does it by turning stderr into an unbounded queue, then making some other part of the stack pay the bill: a pipe fills, a reader retains megabytes, a shutdown waits forever, or an approval card appears after the user has stopped trusting the run.

Treat stderr as untrusted input with a budget. That sounds fussy until you have watched a perfectly ordinary command emit one diagnostic per retry, while the action it was meant to support has already completed and the parent process is still trying to collect the noise. The fix is not "turn off logging." The fix is to drain it continuously, retain only a bounded amount, account for what you discarded, and keep action state independent from text output.

Stderr is a backpressure path

Stderr can block a helper whenever the parent connects it to a pipe and fails to consume that pipe fast enough. Operating systems give pipes finite buffers. Once a child fills that buffer, its next write waits for a reader. If that child only reaches its success path after writing the diagnostic, the action appears to hang even though the network request, SSH connection, or local work was otherwise fine.

The opposite failure is quieter but often more expensive. A parent may read stderr promptly and append every byte to a string, an event buffer, or an MCP tool result. The pipe never fills, yet a chatty child can consume enough memory to slow the host, trigger memory pressure, or make later calls fail. A cap that only protects the pipe is incomplete. A cap that only protects memory is incomplete too.

This matters more with agents because they encourage fanout. One task may start several helpers, and a retry loop can create another burst before the first one drains. A log rate that looks harmless in an interactive terminal becomes a resource problem when several sessions capture it at once.

There are three separate things to bound:

  • Bytes waiting in the operating system pipe.
  • Bytes the bridge retains in memory for a call.
  • Bytes the bridge exposes to an agent, a user interface, or a journal projection.

Do not confuse a line limit with a byte limit. One line can contain a large response body, a certificate chain, or a minified error object. Do not assume UTF-8 arrives in whole characters either. Stream readers receive bytes in chunks, and a limit must still work when a multibyte character crosses a chunk boundary.

A practical reader keeps a byte counter for the full stream and a separate ring buffer for the retained tail. Once the total crosses the cap, it continues draining so the child can exit. It stops growing the retained buffer and records that truncation occurred. Killing the child immediately on the first excess is sometimes right for a tool whose output is itself abusive, but it is the wrong default for diagnostics. You often want the actual exit status and the last lines that explain it.

MCP keeps stdout off limits

An MCP stdio server must treat stdout as protocol territory. The Model Context Protocol specification's stdio transport guidance says that a server must not write anything to stdout other than valid MCP messages. That rule is easy to dismiss as formatting hygiene. In practice, it prevents a much uglier class of failure: a helper's casual progress line can make the host parse invalid JSON and abandon a session that was otherwise sound.

Put human diagnostics on stderr, but do not assume that makes them harmless. Stderr is an out-of-band channel only in the protocol sense. The process host still decides whether it inherits that stream, pipes it, captures it, writes it to a terminal, or forwards it into a structured log. Each choice changes failure behavior.

Inheritance works for a local developer because the terminal consumes output and the developer can see it. It is a poor default for an agent bridge. It leaks arbitrary text into a place that may not be retained, may interleave output from concurrent calls, and may carry details the agent should never receive. Full capture is better for diagnosis only if it has a budget.

Keep the protocol boundary boring. The MCP server should send valid JSON-RPC messages on stdout, keep its own diagnostics bounded on stderr, and launch helpers with their streams under explicit control. A helper should return structured results through the intended channel. It should not print a JSON blob to stderr and hope the parent recognizes it later.

That distinction prevents a recurring mistake: treating stderr as an alternate response channel. It is not. It has no reliable framing, can be incomplete at cancellation, and can contain output from libraries that know nothing about your action model. If a caller needs a retry count, a remote error code, or a list of changed files, add it to the structured result. Reserve stderr for evidence a person may need while diagnosing a failure.

Capturing output needs its own memory budget

Reading stderr in a background task does not make capture safe. It merely moves the bottleneck from the pipe to your heap. I have seen hosts fix a deadlock by reading both streams concurrently, then discover that a bad helper can force them to retain every byte until the action ends. The process now exits, but the host suffers.

Use a ring buffer for retained diagnostics. A ring buffer keeps the most recent bytes after the capacity is reached, which is usually where the useful error appears. Preserve a short prefix as well if your environment makes the first line meaningful, such as a command invocation or library version. Do not preserve both indefinitely.

The following pseudocode describes the behavior worth implementing. It does not depend on a particular language.

on_stderr_chunk(bytes):
  stderr_seen += length(bytes)
  if stderr_seen <= capture_limit:
    append_tail(bytes)
  else:
    append_tail(bytes)       # ring buffer evicts older bytes
    stderr_truncated = true
  continue_reading()

The comment deserves attention. capture_limit should mean the amount you keep, not a point at which you stop reading. A strict implementation can skip append_tail after the limit and retain the first bytes instead. I prefer a tail because failure messages often arrive after pages of progress output. Whichever choice you make, name it in the record so a later investigator knows whether they are reading the beginning or the end.

Place a second cap around the object that carries diagnostics into the agent context. An agent does not need a multi-megabyte transcript to decide whether to retry. It needs a concise error, exit status, and, at most, a selected tail. If the bridge passes raw helper output through because "the model might need it," it gives every helper a path to crowd out the rest of the task context.

Do not decode and re-encode a large stream just to enforce a text limit. Count raw bytes before you build strings. Decode the retained slice with a replacement strategy for invalid sequences, then label it as captured stderr. That avoids both memory waste and false confidence when a helper emits binary data by mistake.

A completed action can still die during shutdown

Process shutdown is where log floods turn into misleading incident reports. The action may have succeeded remotely, the helper may have printed its final diagnostic, and the parent may still fail to produce a result because it waits on the wrong event in the wrong order.

A familiar sequence looks like this:

  1. The bridge starts a helper and begins reading stdout, but stderr reading falls behind during a burst.
  2. The helper completes its external action, then writes enough diagnostics to fill stderr's pipe.
  3. The parent cancels the session or hits a deadline and sends a termination signal.
  4. The parent waits for the child before closing or draining the stream readers.
  5. A reader waits for end of file while another task waits for the reader, and the session never reaches its final record.

The external effect may already exist. An HTTP request may have been accepted or an SSH command may have changed a remote file. Reporting that call as simply "timed out" leaves the operator with the worst possible answer: they do not know whether retrying will repeat a change.

Give every helper one owner that manages four things together: the child handle, the stdout reader, the stderr reader, and cancellation. On normal completion, wait for process exit and drain readers through end of file before constructing the final result. On cancellation, request termination, keep draining both streams, wait for a bounded grace period, then force termination if the platform permits it. Finally, wait for readers to finish and record the exit state you actually observed.

Do not make the stream readers children of the request handler alone. A handler can disappear when a client disconnects. The helper and its readers need ownership that survives long enough to finish cleanup and write the terminal state. Otherwise an agent process can exit, the host can drop the last references to its readers, and a child can remain alive with pipes nobody is consuming.

Process groups need the same care. A shell wrapper may spawn descendants that inherit stderr, and killing only the wrapper can leave a descendant holding the pipe open. Avoid a shell when you can. If you need one, start it in a contained process group and define exactly which descendants cancellation reaches. Then test the case where the wrapper exits but a grandchild continues writing.

Approval timing must ignore log volume

Approve the run, not logs
Approve a new agent process once, then let that run continue until it exits.

Approval timing should follow action state, not the pace of a helper's diagnostics. If your interface only presents approval after a helper has produced a preflight transcript, a noisy preflight changes when the user is asked to decide. That makes approval feel random and trains people to approve cards without understanding why one took longer.

Define the transitions before you write the UI code. A call can be received, validated, awaiting approval, authorized, dispatched, finished, cancelled, or failed before dispatch. Stderr can attach to a call, but it must not decide which transition occurs. The bridge should validate the requested target and parameters, create the call record, and present any required approval before it starts an action that needs approval.

There is one useful exception. A helper may be needed to discover what action will occur, such as resolving a local configuration name to a concrete endpoint. In that case, treat discovery as its own non-action operation with its own output budget. Do not hide an action inside "preflight" and then claim the user approved it later.

Give approval a wall-clock deadline that does not reset because new stderr arrives. Continue collecting the bounded tail while the card is visible, but never redraw the card for every line. A retry warning or an error summary can be useful context if it arrives before approval, yet it should appear as a stable explanation, not as an endless moving log view.

Separate consent from liveness in your tests. A flood test should measure the time from a valid call request to an approval card, with stderr produced both before and after that point. A slow-reader test should verify that the card remains usable while the bridge drains. A cancellation test should verify that dismissing the card leaves no helper process running and produces a terminal record.

The human consequence is plain: an approval card must describe a particular action at a particular moment. If log output can delay, alter, or outlive that moment, the interface is reporting the process's confusion rather than giving the user control.

Call records need lifecycle facts before diagnostics

A call record is complete when it explains the action's state, not when it contains every line a helper emitted. Log output is evidence. Lifecycle events are the record.

Write the attempt before dispatch. Include a stable call identifier, the session identifier, the requested action type, the approved or denied decision, and the target information that the user was shown. When dispatch begins, append that fact. When it ends, append the observed result: success, remote failure, local failure, cancellation, forced termination, or an unknown outcome caused by losing the process boundary.

Then attach diagnostic metadata. At minimum, record the total stderr bytes seen, bytes retained, whether truncation occurred, the helper's exit status if available, and whether the stream ended cleanly. This makes a short retained tail honest. A later reader can see the difference between "the command printed this" and "the bridge kept the last part of what the command printed."

A record shape can be this small:

{
  "call_id": "c_7f2a",
  "state": "cancelled_after_dispatch",
  "stderr_bytes_seen": 184320,
  "stderr_bytes_retained": 16384,
  "stderr_truncated": true,
  "exit_status": null,
  "stream_end": "reader_completed_after_cancel"
}

Do not write exit_status: 0 because a parent received a successful response body. A response body and a child exit are different observations. Do not write state: failed when cancellation happened after dispatch and the remote side may have acted. That distinction looks pedantic during implementation and becomes the difference between a safe investigation and a blind retry at two in the morning.

The NIST SP 800-92 Guide to Computer Security Log Management makes a useful point: log management includes generation, transmission, storage, analysis, and disposal, not merely collecting text. Apply that reasoning to agent actions. If collection can prevent completion, the logging path has become part of execution. It needs limits, state, and failure handling like any other execution path.

Sallyport projects its Sessions and Activity journals from one write-blind encrypted, hash-chained audit log, so a caller can distinguish the agent run from the individual action records rather than relying on a helper transcript as history.

Bound the noise in three places

Check sensitive calls individually
Mark a key for per-call approval when every use deserves a fresh human decision.

One cap at the top of the stack leaves too much room for accidents. Set limits at the helper, the bridge, and the diagnostic destination. Each limit protects a different boundary.

First, make helpers less chatty by default. Put routine progress behind an explicit debug setting, emit one summary for a retry sequence, and avoid printing request or response bodies by default. A helper should never write credentials, authorization headers, or private key material to stderr. Redaction after capture is a useful backstop, but it cannot undo a secret that already sat in a terminal, a crash report, or an unbounded buffer.

Second, make the bridge drain continuously and retain a bounded prefix or tail. It should enforce a maximum duration and a maximum retained byte count for each stream. It should also have a process-wide diagnostic budget. Without that last control, fifty calls that each stay under their per-call allowance can still create a memory spike together.

Third, limit the destination. If you export call diagnostics to a user interface, an agent response, or a file, impose another cap there. A journal can retain structured lifecycle fields and a digest of discarded output without storing a repeated stack trace from every retry.

Use an explicit configuration shape. The names do not matter. The separation does.

helper_output:
  stderr_retained_per_call_bytes: 16384
  stderr_retained_process_bytes: 262144
  stderr_agent_excerpt_bytes: 4096
  shutdown_grace_seconds: 5
  retain: tail

This configuration prevents a common failure where a team sets an agent response limit and assumes the host is protected. The host still reads and stores the full data before it trims the response. The retained per-call cap protects one action. The process cap protects concurrent actions. The agent excerpt cap protects context that a model must share with the rest of its work.

Do not use a single global switch called quiet. It makes production failures harder to diagnose and encourages developers to re-enable unlimited logs when they need evidence. Keep normal output concise, expose a controlled debug mode for a short window, and preserve the fact that the debug mode was active in the call record.

Avoid the popular recommendation to redirect stderr to /dev/null. People recommend it because it removes the immediate stall and keeps agent responses tidy. It also deletes the first useful clue when an SSH helper cannot authenticate, a certificate check fails, or a remote command returns an unexpected error. Drain, bound, and label the stream instead.

Truncation should be visible, not dramatic

Truncation is safe when it is explicit and when the system keeps draining. It is unsafe when a later reader cannot tell whether an error message is complete, when the bridge stops reading and blocks the child, or when the discarded output may contain the only account of an action that has no other record.

The retained tail should begin with a marker produced by the bridge, not by the helper. For example:

[stderr truncated: kept last 16384 of 184320 bytes]
connection retry 18 failed: remote side closed the channel

That marker is part of the record, not decoration. It tells the user why the first visible line looks abrupt and prevents an agent from treating a partial stack trace as a full explanation. If you keep a prefix and a tail, state both byte counts. Never silently splice them together.

Set caps by working backward from concurrency, not by copying a number from another project. Ask how many actions one agent run can have in flight, how many runs the host accepts, how much memory diagnostics may consume during a bad minute, and how much text a person can usefully inspect. The last answer is usually much smaller than people expect.

Keep the action result separate from the excerpt. A successful HTTP action should return its intentional structured result even if stderr hit its cap, unless the helper itself uses the overflow as a failure signal. Conversely, a clean stderr stream does not prove an action succeeded. Treat diagnostics as one field among several observations.

Flood tests belong beside normal integration tests

Keep API keys out of agents
Sallyport injects bearer, basic, or custom-header credentials only when it makes the HTTP call.

A helper that writes too much stderr is not an exotic security test. It is a basic reliability test. Libraries get verbose after a version change, remote clients repeat warnings during retries, and a malformed input can trigger an error loop. If the bridge only sees quiet happy-path helpers in tests, it has no evidence that its process handling works under pressure.

Start with a fixture that emits a fixed amount of stderr, exits with a chosen status, and does no external work. On a Unix-like system, this command creates a deliberate flood for a local test harness:

yes helper-diagnostic 1>&2

Run it under a short deadline. The expected result is not merely that the deadline fires. Confirm that the bridge retains no more than its configured amount, marks truncation, terminates the child, drains streams until they close, and writes a terminal lifecycle event.

Then add the cases that expose ordering errors:

  • A helper writes stderr before it waits for approval.
  • A helper writes during approval and exits immediately after approval.
  • A helper succeeds externally, floods stderr, and receives cancellation during cleanup.
  • A wrapper exits while a descendant keeps stderr open.
  • A helper writes invalid byte sequences and a single line larger than the retained cap.

Measure a small set of facts for every case: peak retained diagnostic bytes, time to approval, time from cancellation to process exit, final state, and presence of the expected call record. Do not settle for a test that only asserts that an error string contains "truncated." That string can appear while a reader task remains blocked or while the final record never reaches storage.

Run the same fixtures with concurrent calls. A per-call cap can look correct in isolation and still fail when all calls hit their caps together. Also test a client disconnect. The agent process is allowed to go away; the host still needs to finish or cancel the action in a defined way.

The final check is offline verification of the audit chain and a comparison against the fixture's expected lifecycle. Verification can tell you whether stored entries were altered. The fixture comparison tells you whether the bridge wrote the entries it should have written in the first place. You need both.

Keep logs useful without letting them run the action

The useful rule is simple: stdout carries MCP protocol messages, structured results carry action outcomes, and stderr carries bounded diagnostics. Once those channels have different jobs, approval timing, shutdown, memory accounting, and audit records stop fighting over the same unstructured text.

Start with the helper that has embarrassed you before: the one that retries too loudly, hangs after a cancellation, or prints a whole remote response when it fails. Put it behind a bounded reader and run the flood fixture until the action record stays truthful. Quiet logs are pleasant. Bounded logs are operational control.

FAQ

Can stderr output really stall an MCP agent?

Yes. If the parent process captures stderr without a limit, a helper can turn diagnostic output into retained memory or fill a pipe that blocks the helper. The MCP messages may be healthy while the process carrying them is stuck behind its own logs.

Should an MCP server write logs to stdout or stderr?

Do not send logs to stdout. The stdio transport reserves stdout for protocol messages, so one stray diagnostic line can corrupt the JSON-RPC stream. Send diagnostics to stderr and make the host drain and bound that stream.

What stderr limit should I set for an agent helper?

A good starting point is a small byte cap per call, a smaller retained tail, and a hard process-wide cap. The exact numbers depend on concurrency and available memory, but the cap must be explicit and observable rather than an accidental property of a host runtime.

Is it safe to truncate helper stderr?

Keep a bounded tail, record the total bytes seen, and mark the call as truncated. A complete audit record does not require a complete copy of every debug line; it requires an honest account of what ran, what happened, and what evidence was discarded.

Can verbose logs delay an approval prompt?

Approval should track the action lifecycle, not the volume of logs. Start the approval clock when the action becomes eligible for human approval, and keep draining stderr while the card is visible.

Do I need to drain stdout and stderr at the same time?

Always read both streams concurrently. Draining only stdout can deadlock a process that fills its stderr pipe, and draining only stderr can create the same failure in reverse for a helper that emits a large response.

What should an audit log record when a helper is killed?

Record an attempted action before dispatch, then record whether dispatch started, whether it completed, and whether output was truncated. If the helper outlives the client or the client exits first, the journal should say so instead of implying a clean result.

Will a timeout protect me from log flooding?

No. A short timeout bounds elapsed time, while a stderr cap bounds retained data and pipe pressure. You need both, plus a cancellation path that closes streams and waits for process exit.

How do I test noisy MCP helpers?

Use a real helper that emits a controlled amount of stderr, then run it through the same bridge and approval path used in production. Measure peak retained bytes, time to approval, cancellation latency, exit status, and whether every lifecycle record appears.

Why should an action gateway bound subprocess logs?

A gateway that executes actions on behalf of an agent should treat helper output as untrusted input. It should preserve the useful tail and diagnostics while preventing an agent or a buggy dependency from consuming memory or holding an action open indefinitely.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov