Skip to content

Unhandled 'EPIPE' in 'StdioServerTransport' causes fatal process crash on client disconnect #1564

Closed

Description

@manthanghasadiya

Description

There is a stability issue in the StdioServerTransport implementation of the TypeScript SDK. If an MCP client sends a request but abruptly closes the stdio pipes (stdin/stdout) before the server can write the response, the Node.js process throws an unhandled EPIPE exception and fatally crashes.

Because stdout.write() is not wrapped in a try/catch or lacking an 'error' event listener on the stream, standard client-side timeouts or abrupt disconnects lead to a complete server crash rather than a graceful shutdown. This acts as a Denial of Service (DoS) for local AI agents relying on the server.

Steps to Reproduce

Here is a minimal Python Proof of Concept (PoC) that reproduces the crash by initiating a valid handshake and instantly closing the pipe:

import subprocess
import time
import json

# 1. Start any MCP server using the TS SDK (e.g., server-everything)
process = subprocess.Popen(
    ["npx", "-y", "@modelcontextprotocol/server-everything"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)

# 2. Send a valid JSON-RPC initialization request
req = {
    "jsonrpc": "2.0", "id": 1, "method": "initialize", 
    "params": {
        "protocolVersion": "2024-11-05", 
        "capabilities": {}, 
        "clientInfo": {"name": "poc", "version": "1.0"}
    }
}
process.stdin.write(json.dumps(req) + "\n")
process.stdin.flush()

# 3. Instantly close the pipes before Node can reply
process.stdin.close()
process.stdout.close()

# 4. Read the stderr crash log
time.sleep(1)
print(process.stderr.read())

Actual Behavior (Stack Trace)

node:events:486
      throw er; // Unhandled 'error' event
      ^
Error: EPIPE: broken pipe, write
    at Socket._write (node:internal/net:75:18)
    at writeOrBuffer (node:internal/streams/writable:570:12)
    at _write (node:internal/streams/writable:499:10)
    at Writable.write (node:internal/streams/writable:508:10)
    at file:///.../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js:66:30
    at new Promise (<anonymous>)
    at StdioServerTransport.send (file:///.../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js:64:16)

Expected Behavior

The transport layer should catch the EPIPE error and handle it gracefully (e.g., emitting a close event and shutting down the session cleanly) rather than bubbling up as an unhandled exception that kills the host process.

Activity

  1. added a commit that references this issue on Feb 22, 2026
    af8ccdc
  2. sstklen commented on Feb 25, 2026

    @sstklen

    Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.

    What's happening: StdioServerTransport.send() calls stdout.write() without any error handling. When a client disconnects abruptly (closes stdin/stdout pipes), the subsequent write to the now-broken stdout pipe emits an unhandled 'EPIPE' error. Node.js treats unhandled stream errors as fatal exceptions, crashing the entire server process. The core issue is that stream.write() can fail asynchronously via the 'error' event or synchronously throw, and neither path is caught in the current implementation.

    What worked for us:

    Add error handling around stdout.write() in StdioServerTransport: (1) attach an 'error' event listener on the stdout stream to catch async EPIPE/ERR_STREAM_DESTROYED errors gracefully, and (2) wrap the write() call in try/catch for synchronous throws. On EPIPE, log a debug message and trigger graceful shutdown instead of crashing.

    Steps:

    1. In StdioServerTransport constructor or start(), add stdout.on('error', handler) that catches EPIPE/ERR_STREAM_DESTROYED and calls close() gracefully instead of letting it propagate as uncaught
    2. Wrap the stdout.write() call in send() with try/catch to handle synchronous write errors on an already-destroyed stream
    3. In the error handler, check for err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED' and treat these as client-disconnect signals rather than fatal errors
    4. Optionally add process.on('SIGPIPE', () => {}) as a defense-in-depth measure to ignore SIGPIPE at the process level (Node.js default already ignores it, but Bun may not)
    // In StdioServerTransport class:
    
    start() {
      // ... existing setup ...
      
      // Add error listener to prevent unhandled EPIPE crash
      this._stdout.on('error', (err) => {
        if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
          // Client disconnected — graceful shutdown, not a crash
          this.close();
        } else {
          this.onerror?.(err);
        }
      });
    }
    
    async send(message: JSONRPCMessage): Promise<void> {
      try {
        const json = JSON.stringify(message);
        if (this._stdout.writable) {
          this._stdout.write(json + '\n');
        }
      } catch (err: any) {
        if (err?.code === 'EPIPE' || err?.code === 'ERR_STREAM_DESTROYED') {
          await this.close();
        } else {
          throw err;
        }
      }
    }

    📊 We found 3 similar cases in our knowledge base with the same pattern — this gives us high confidence in this analysis.

    Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞

    Disclosure: This analysis is from Confucius Debug, an AI-powered community KB for agent bugs. Please verify before applying.


    🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.

  3. manthanghasadiya commented on Feb 25, 2026

    @manthanghasadiya
    Author

    @sstklen Thanks for the analysis! This matches what I found through fuzzing with mcpsec. PR #1566 is already addressing this - hopefully it gets merged soon.

    The core issue is that any MCP server using StdioServerTransport will crash on client disconnect, which is a DoS vector.

  4. xXMrNidaXx commented on Feb 25, 2026

    @xXMrNidaXx

    This is a critical issue for production deployments. EPIPE handling should definitely be defensive in any transport layer.

    Immediate workaround:

    You can patch this at the process level until the SDK is fixed:

    // Add at the top of your MCP server entry point
    process.stdout.on('error', (err) => {
      if (err.code === 'EPIPE') {
        // Client disconnected - graceful exit
        console.error('[MCP] Client disconnected (EPIPE)');
        process.exit(0);
      }
      throw err; // Re-throw other errors
    });
    
    // Also catch uncaughtException as a fallback
    process.on('uncaughtException', (err) => {
      if (err.code === 'EPIPE') {
        process.exit(0);
      }
      console.error('Uncaught exception:', err);
      process.exit(1);
    });

    More robust wrapper for the transport:

    // Wrap the send method with EPIPE handling
    const originalSend = transport.send.bind(transport);
    transport.send = async (message: JsonRpcMessage) => {
      try {
        await originalSend(message);
      } catch (err: any) {
        if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
          // Emit close event and clean up
          transport.emit('close');
          return;
        }
        throw err;
      }
    };

    Suggested SDK fix:

    In StdioServerTransport.send(), the stdout.write() call at line 66 should be wrapped in try/catch, and the stdout stream should have an 'error' listener registered in the constructor:

    constructor() {
      // ... existing code ...
      process.stdout.on('error', (err) => {
        if (err.code === 'EPIPE') {
          this.close();
        }
      });
    }

    Happy to submit a PR if helpful.

  5. added a commit that references this issue on Feb 26, 2026
    5bfe860
  6. added a commit that references this issue on Mar 8, 2026
    75c8800
  7. added a commit that references this issue on Mar 12, 2026
    546d56f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions