Security reference · DoS · File System

MCP server file descriptor leak security

Every open file, socket, and pipe in a Node.js process consumes a file descriptor (FD). Linux enforces a per-process FD limit (ulimit -n, typically 1024 or 65536 depending on configuration). When an MCP server tool handler opens a stream and fails to close it on every code path — including error paths — the leaked FDs accumulate. When the process reaches the limit, it can no longer open files, accept connections, or create pipes, and starts throwing EMFILE: too many open files. From an attacker's perspective, a tool handler with a consistent FD leak becomes a denial-of-service vector: call the tool enough times to exhaust the FD budget, then the entire MCP server becomes unavailable.

The leak pattern: missing cleanup on error paths

The most common FD leak in MCP tool handlers is fs.createReadStream() called inside a try block without a guarantee that the stream is destroyed on all code paths:

// VULNERABLE: ReadStream left open if processing throws
async function readAndProcessFile(filePath: string): Promise<string> {
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
  try {
    const content = await streamToString(stream);
    const result = await processContent(content);   // If this throws, stream leaks
    return result;
  } catch (err) {
    // stream.destroy() is NOT called — FD remains open
    throw err;
  }
}

This leaks an FD every time processContent() throws. In a long-running MCP server handling many tool calls, this accumulates until the process hits EMFILE. The fix is to guarantee stream cleanup in a finally block:

// CORRECT: explicit destroy in finally
async function readAndProcessFile(filePath: string): Promise<string> {
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
  try {
    const content = await streamToString(stream);
    return await processContent(content);
  } finally {
    stream.destroy();  // Always closes the FD, even if already ended normally
  }
}

The pipe() problem: errors don't propagate

Node.js's legacy stream.pipe() does not destroy the source stream when the destination emits an error, and does not propagate destination errors to the source. This creates two FD leak vectors:

// VULNERABLE: pipe() does not clean up on write errors
const readStream = fs.createReadStream(inputPath);
const writeStream = fs.createWriteStream(outputPath);

readStream.pipe(writeStream);
// If writeStream emits 'error', readStream remains open
// If readStream emits 'error', writeStream may receive partial data and stall

// CORRECT: use pipeline() from stream/promises
// pipeline() properly destroys all streams in the chain on any error
import { pipeline } from 'stream/promises';
import { createGzip } from 'zlib';

async function compressFile(inputPath: string, outputPath: string): Promise<void> {
  // pipeline() destroys ALL streams in the chain if any one throws
  await pipeline(
    fs.createReadStream(inputPath),
    createGzip(),
    fs.createWriteStream(outputPath),
  );
  // After pipeline() resolves or rejects, all streams are destroyed
}

pipeline() vs pipe(): Always prefer pipeline() from stream/promises over .pipe(). The pipeline() function registers error handlers on all streams, and if any stream emits an error, it destroys all other streams in the chain and rejects the returned promise. The legacy .pipe() has no such guarantee.

fs.promises.open: the FileHandle lifecycle

When using fs.promises.open() directly, the returned FileHandle object holds an FD that must be explicitly closed. Forgetting to close it — especially on error paths — leaks the FD permanently for the lifetime of the process:

// VULNERABLE: FileHandle not closed if processing throws
async function readWithHandle(filePath: string): Promise<string> {
  const handle = await fs.promises.open(filePath, 'r');
  const content = await handle.readFile({ encoding: 'utf-8' });
  await handle.close();  // Never reached if readFile throws
  return content;
}

// CORRECT: close in finally
async function readWithHandle(filePath: string): Promise<string> {
  const handle = await fs.promises.open(filePath, 'r');
  try {
    return await handle.readFile({ encoding: 'utf-8' });
  } finally {
    await handle.close();
  }
}

// BEST: Node.js 18.15+ Symbol.asyncDispose (using declaration)
// FileHandle implements Symbol.asyncDispose, so 'using' auto-closes
// Requires TypeScript 5.2+ and Node 22+
async function readWithUsing(filePath: string): Promise<string> {
  await using handle = await fs.promises.open(filePath, 'r');
  return handle.readFile({ encoding: 'utf-8' });
  // handle.close() called automatically at end of 'using' scope
}

MCP tool handler pattern: streaming large files

MCP tool handlers that stream file content to the LLM context need to ensure cleanup on both normal completion and error paths, and also enforce a maximum response size to prevent context flooding:

import { pipeline } from 'stream/promises';
import { Transform } from 'stream';

const MAX_READ_BYTES = 512 * 1024;  // 512 KB hard cap for LLM context

async function readFileTool(args: { path: string }): Promise<string> {
  const safePath = validateSafePath(args.path, process.env.ALLOWED_ROOT!);

  const chunks: Buffer[] = [];
  let totalBytes = 0;
  let truncated = false;

  const collector = new Transform({
    transform(chunk: Buffer, _enc, cb) {
      if (totalBytes >= MAX_READ_BYTES) {
        truncated = true;
        cb();  // Swallow remaining bytes
        return;
      }
      const remaining = MAX_READ_BYTES - totalBytes;
      const slice = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
      chunks.push(slice);
      totalBytes += slice.length;
      cb(null, slice);
    }
  });

  try {
    await pipeline(
      fs.createReadStream(safePath),
      collector,
    );
  } catch (err: any) {
    if (err.code === 'ENOENT') throw new Error('file_not_found');
    if (err.code === 'EACCES') throw new Error('file_permission_denied');
    throw err;
    // pipeline() guarantees ReadStream is destroyed on any error
  }

  const content = Buffer.concat(chunks).toString('utf-8');
  return truncated ? `${content}\n[TRUNCATED at ${MAX_READ_BYTES} bytes]` : content;
}

Detecting FD leaks with process monitoring

Add an FD monitoring interval to detect leaks before they cause EMFILE. On Linux, /proc/self/fd lists all open FDs:

import fs from 'fs';

// Log current FD count every 60 seconds — spike indicates leak
setInterval(() => {
  try {
    const fds = fs.readdirSync('/proc/self/fd').length;
    const limit = Number(process.env.FD_LIMIT ?? 1024);
    const ratio = fds / limit;

    console.log(JSON.stringify({
      event: 'fd_monitor',
      open_fds: fds,
      limit,
      utilization: `${(ratio * 100).toFixed(1)}%`,
      timestamp: new Date().toISOString(),
    }));

    if (ratio > 0.8) {
      console.error(JSON.stringify({
        event: 'fd_high_watermark',
        open_fds: fds,
        threshold: 0.8,
        timestamp: new Date().toISOString(),
      }));
    }
  } catch { /* /proc not available on macOS — skip */ }
}, 60_000).unref();

SkillAudit findings

Critical File descriptor leak on error path in high-frequency tool handler — EMFILE DoS confirmed reproducible. Security axis −20 pts.
High Legacy .pipe() used without error handler on source and destination — FD leak on downstream write error. Security axis −16 pts.
High FileHandle from fs.promises.open() not closed in finally block — permanent FD leak per failed tool call. Security axis −14 pts.
Medium No FD monitoring or alerting — leak accumulates undetected until EMFILE. Maintenance axis −8 pts.

Run a SkillAudit scan to detect file descriptor leaks, unclosed streams, and missing error-path cleanup across your MCP server's tool handlers.