Skip to content

stdio mode: EPIPE crash — console.log and detectExternalMCPs write to stdout corrupting MCP JSON-RPC channel #46

@maximus12793

Description

@maximus12793

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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions