Summary
When a command (e.g. mcporter call ... / mcporter list --schema --output json) prints a large result to a pipe, the output is silently truncated at exactly 65536 bytes (64KB). Writing the same result to a file (> out.json) produces the complete output.
Root cause: after the command finishes, closeRuntimeAfterCommand() schedules a forced process.exit() ~50ms later without waiting for stdout to drain. On POSIX, process.stdout writes to a pipe are asynchronous — console.log() queues the data in libuv and returns immediately. The kernel pipe buffer only holds ~64KB, so any remaining bytes are still in libuv's write queue when the process is killed, and the pipe reader never receives them. (Writes to a file are synchronous, which is why > file is unaffected.)
Reproduction
# stdout is a pipe -> truncated to 65536 bytes
mcporter call <server> <tool> ... | wc -c # => 65536 (truncated)
mcporter call <server> <tool> ... | cat > out # => 65536 (truncated)
# stdout is a file -> complete
mcporter call <server> <tool> ... > out.json # => e.g. 71846 (complete)
Use any tool/list whose serialized output exceeds ~64KB.
Root cause (source pointers)
-
src/cli/output-utils.ts — large result is emitted in one shot:
console.log(JSON.stringify(value, null, 2));
For a pipe this is a non-blocking process.stdout.write(); the data is queued in libuv and flushed asynchronously.
-
src/cli.ts — const FORCE_EXIT_GRACE_MS = 50;
-
src/cli.ts closeRuntimeAfterCommand() — after cleanup, a forced exit is scheduled (force-exit is on by default; opt out via MCPORTER_NO_FORCE_EXIT=1):
const scheduleForcedExit = () => {
if (shouldForceExit) {
setTimeout(() => {
process.exit(process.exitCode ?? 0); // kills the process before stdout drains
}, FORCE_EXIT_GRACE_MS);
}
};
There is no hardcoded 64KB limit in the source (65536 / 64 * 1024 / maxBuffer all have 0 matches); 64KB is the OS pipe buffer capacity. There is also no stdout drain handling anywhere (writableNeedDrain / once('drain') have 0 matches).
Why > file works
File writes are synchronous on POSIX and don't go through the pipe buffer, so the full payload reaches the fd before exit.
Timeline
| t |
event |
stdout pipe state |
| T+0ms |
console.log(72KB) returns |
first 64KB lands in kernel pipe buffer (full); ~6KB still queued in libuv |
| T+0–1ms |
finally -> closeRuntimeAfterCommand() -> runtime.close() + terminateChildProcesses() |
— |
| T+1ms |
setImmediate schedules 50ms timer |
— |
| T+51ms |
process.exit(0) |
remaining ~6KB never flushed -> reader sees only 65536 bytes |
Proposed fix
Flush stdout before the forced exit (no-op for TTY/file; only waits when a pipe write is backpressured), with a safety cap so a stalled reader can't hang the process:
setTimeout(() => {
process.stdout.write('', () => {
process.exit(process.exitCode ?? 0);
});
}, FORCE_EXIT_GRACE_MS);
Environment
- mcporter
0.12.1
- OS: POSIX (pipe buffer 64KB); reproduces wherever stdout is a pipe
Summary
When a command (e.g.
mcporter call .../mcporter list --schema --output json) prints a large result to a pipe, the output is silently truncated at exactly 65536 bytes (64KB). Writing the same result to a file (> out.json) produces the complete output.Root cause: after the command finishes,
closeRuntimeAfterCommand()schedules a forcedprocess.exit()~50ms later without waiting forstdoutto drain. On POSIX,process.stdoutwrites to a pipe are asynchronous —console.log()queues the data in libuv and returns immediately. The kernel pipe buffer only holds ~64KB, so any remaining bytes are still in libuv's write queue when the process is killed, and the pipe reader never receives them. (Writes to a file are synchronous, which is why> fileis unaffected.)Reproduction
Use any tool/list whose serialized output exceeds ~64KB.
Root cause (source pointers)
src/cli/output-utils.ts— large result is emitted in one shot:For a pipe this is a non-blocking
process.stdout.write(); the data is queued in libuv and flushed asynchronously.src/cli.ts—const FORCE_EXIT_GRACE_MS = 50;src/cli.tscloseRuntimeAfterCommand()— after cleanup, a forced exit is scheduled (force-exit is on by default; opt out viaMCPORTER_NO_FORCE_EXIT=1):There is no hardcoded 64KB limit in the source (
65536/64 * 1024/maxBufferall have 0 matches); 64KB is the OS pipe buffer capacity. There is also nostdoutdrain handling anywhere (writableNeedDrain/once('drain')have 0 matches).Why
> fileworksFile writes are synchronous on POSIX and don't go through the pipe buffer, so the full payload reaches the fd before exit.
Timeline
console.log(72KB)returnsfinally->closeRuntimeAfterCommand()->runtime.close()+terminateChildProcesses()setImmediateschedules 50ms timerprocess.exit(0)Proposed fix
Flush
stdoutbefore the forced exit (no-op for TTY/file; only waits when a pipe write is backpressured), with a safety cap so a stalled reader can't hang the process:Environment
0.12.1