Summary
When running hypertool in stdio transport mode (the default), the process crashes with an EPIPE error before the MCP handshake can complete. The host client (e.g. Goose, Claude Desktop) closes the pipe because hypertool never responds to initialize, and the crash is triggered by writes to stdout that corrupt the MCP JSON-RPC channel.
Environment
@toolprint/hypertool-mcp v0.0.45 / v0.0.46
- Node.js v24.9.0
- macOS (Sonoma)
- Host: Goose (Block's open-source AI agent)
Error
[ERROR] Uncaught exception:
error: {
"name": "Error",
"message": "write EPIPE",
"stack":
Error: write EPIPE
at afterWriteDispatched (node:internal/stream_base_commons:159:15)
...
at Object.displaySpaceBuffer (dist/utils/output.js:111:31)
at EnhancedMetaMCPServer.initializeRouting (dist/server/enhanced.js:523:24)
at async EnhancedMetaMCPServer.start (dist/server/enhanced.js:300:9)
}
Root Causes (3 bugs)
Bug 1 — output.js: getStdioDisplay() falls back to stdout when logging config is uninitialised
// output.js
const getStdioDisplay = () => {
const currentLoggingConfig = getActiveLoggingConfig();
// BUG: when config is null (early startup), this condition is false
// and the fallback Console writes to stdout — corrupting the MCP pipe
if (currentLoggingConfig && !currentLoggingConfig.enableConsole) {
return new Console({ stdout: process.stderr, stderr: process.stderr });
}
return new Console({ stdout: process.stdout, stderr: process.stderr }); // ← stdout!
};
During initializeRouting(), the logging config has not yet been initialised, so getActiveLoggingConfig() returns null. The guard evaluates to false and all output.displaySpaceBuffer() / output.log() calls go to stdout.
Fix: default to stderr when config is not yet available:
if (!currentLoggingConfig || !currentLoggingConfig.enableConsole) {
Bug 2 — enhanced.js: bare console.log() in group-server load path
// enhanced.js ~line 264
console.log(`Loaded ${groupServers.length} servers from group...`);
Uses raw console.log instead of output.log(), bypassing the stdio-safe routing entirely. Should be output.log(...).
Bug 3 — enhanced.js: detectExternalMCPs() warning printed to stdout in stdio mode
// enhanced.js ~line 521-527
const externalMCPs = await detectExternalMCPs();
if (externalMCPs.length > 0) {
output.displaySpaceBuffer();
const message = formatExternalMCPsMessage(externalMCPs);
console.log(chalk.yellow(message)); // ← stdout, always
output.displaySpaceBuffer();
}
This is the direct crash trigger. When a host like Goose has its own MCP config, detectExternalMCPs() finds them and attempts to print a warning. Due to Bug 1, output.displaySpaceBuffer() writes to stdout, and the bare console.log does too — all before super.start() has connected the StdioServerTransport. The host's pipe is already closed waiting for initialize, so the write raises EPIPE.
Fix: guard the entire block behind a stdio check:
if (options.transport.type !== 'stdio') {
const externalMCPs = await detectExternalMCPs();
if (externalMCPs.length > 0) {
output.displaySpaceBuffer();
const message = formatExternalMCPsMessage(externalMCPs);
output.log(chalk.yellow(message));
output.displaySpaceBuffer();
}
}
Underlying Architecture Note
The deeper issue is that initializeRouting() — which connects all downstream MCP servers — is called before super.start() connects the StdioServerTransport. This means the host's initialize request sits unread on stdin while hypertool is busy spawning downstream processes. Any stdout write during this window is fatal.
A longer-term fix would be to start the stdio transport first (so initialize can be answered immediately) and connect downstream servers concurrently/lazily. But the three patches above resolve the immediate crash.
Workaround
Until fixed upstream, I'm using a wrapper script that re-patches the installed dist on every launch (idempotent):
#!/usr/bin/env bash
# Re-applies stdio safety patches after npm updates
DIST="$(npm root -g)/@toolprint/hypertool-mcp/dist"
# Patch 1: safe stderr default when config uninitialised
sed -i '' \
's/if (currentLoggingConfig && !currentLoggingConfig\.enableConsole)/if (!currentLoggingConfig || !currentLoggingConfig.enableConsole)/g' \
"$DIST/utils/output.js"
# Patch 2: group server load path
sed -i '' \
"s/console\.log(\`Loaded \${groupServers/output.log(\`Loaded \${groupServers/g" \
"$DIST/server/enhanced.js"
exec npx -y @toolprint/hypertool-mcp "$@"
Happy to open a PR with the fixes if that would help!
Summary
When running hypertool in stdio transport mode (the default), the process crashes with an
EPIPEerror before the MCP handshake can complete. The host client (e.g. Goose, Claude Desktop) closes the pipe because hypertool never responds toinitialize, and the crash is triggered by writes tostdoutthat corrupt the MCP JSON-RPC channel.Environment
@toolprint/hypertool-mcpv0.0.45 / v0.0.46Error
Root Causes (3 bugs)
Bug 1 —
output.js:getStdioDisplay()falls back tostdoutwhen logging config is uninitialisedDuring
initializeRouting(), the logging config has not yet been initialised, sogetActiveLoggingConfig()returnsnull. The guard evaluates tofalseand alloutput.displaySpaceBuffer()/output.log()calls go to stdout.Fix: default to stderr when config is not yet available:
Bug 2 —
enhanced.js: bareconsole.log()in group-server load pathUses raw
console.loginstead ofoutput.log(), bypassing the stdio-safe routing entirely. Should beoutput.log(...).Bug 3 —
enhanced.js:detectExternalMCPs()warning printed to stdout in stdio modeThis is the direct crash trigger. When a host like Goose has its own MCP config,
detectExternalMCPs()finds them and attempts to print a warning. Due to Bug 1,output.displaySpaceBuffer()writes to stdout, and the bareconsole.logdoes too — all beforesuper.start()has connected theStdioServerTransport. The host's pipe is already closed waiting forinitialize, so the write raisesEPIPE.Fix: guard the entire block behind a stdio check:
Underlying Architecture Note
The deeper issue is that
initializeRouting()— which connects all downstream MCP servers — is called beforesuper.start()connects theStdioServerTransport. This means the host'sinitializerequest sits unread on stdin while hypertool is busy spawning downstream processes. Any stdout write during this window is fatal.A longer-term fix would be to start the stdio transport first (so
initializecan be answered immediately) and connect downstream servers concurrently/lazily. But the three patches above resolve the immediate crash.Workaround
Until fixed upstream, I'm using a wrapper script that re-patches the installed dist on every launch (idempotent):
Happy to open a PR with the fixes if that would help!