Blog · MCP Server Security

MCP server Streams API security — ReadableStream pull timing oracle, backpressure as DoS amplifier, stream cancel safety, and WritableStream abort handling for MCP streaming responses

The WHATWG Streams API is the foundation for MCP streaming responses — Server-Sent Events over a ReadableStream, chunked tool output via TransformStream, streaming file uploads via WritableStream. Streams introduce security concerns that differ from request/response: backpressure from a slow reader forces server-side buffering without bound; stream cancel without a cancel handler leaks the underlying resource; pull timing reveals server processing rate; and pipelining through TransformStream passes unsanitized chunks between stages.

ReadableStream pull timing as a processing rate oracle

A ReadableStream with a pull strategy calls the pull() method when the internal queue falls below the desired size. For an MCP server streaming chunked tool results, the interval between pull() calls on the server side is determined by how fast the client reads chunks. If an attacker reads at a fixed rate and observes when chunks arrive, the inter-chunk timing reveals how fast the server is generating output.

More directly: the time between the client sending a tool call request and receiving the first chunk reveals backend processing latency. The time to receive all chunks reveals total output size. Neither is inherently a secret, but in a multi-tenant MCP deployment, the timing of one tenant's stream affects the backpressure experienced by other streams sharing the same event loop.

// Server-side: MCP streaming tool response
function createToolResponseStream(toolResult, signal) {
  const chunks = chunkResponse(toolResult);
  let index = 0;

  return new ReadableStream({
    pull(controller) {
      if (signal?.aborted) {
        controller.error(new DOMException('AbortError', 'AbortError'));
        return;
      }
      if (index >= chunks.length) {
        controller.close();
        return;
      }
      controller.enqueue(chunks[index++]);
    },
    cancel(reason) {
      // REQUIRED: cleanup when client cancels the stream
      cleanupToolExecution(reason);
    }
  }, new CountQueuingStrategy({ highWaterMark: 3 })); // limit buffering to 3 chunks
}

The highWaterMark: 3 in the CountQueuingStrategy limits how many chunks the stream buffers internally before backpressure kicks in. Without this limit, the stream attempts to read all chunks from the source immediately, buffering them in memory until the client reads them — unlimited buffer growth per slow client.

Backpressure as DoS amplification

Backpressure is how streams signal to the producer to slow down. When the client reads slowly (or not at all), the stream's internal queue fills, and the pull() method is not called again until space opens up. This is the correct behavior for flow control. The DoS amplification comes from the asymmetry: a single slow-reading client can hold server-side buffers full for each open stream, consuming memory proportional to the buffer size × number of open streams.

For an MCP server streaming large tool results (database query results, file contents, API pagination), the buffer per stream can be several MB. An attacker who opens many streaming connections and reads at minimal rate holds large buffers open simultaneously:

Attack scenario: Attacker opens 1,000 streaming MCP connections and reads at 1 byte/second. Each stream has a 1 MB server-side buffer. Total memory consumed: 1 GB. Server OOM-kills or exhausts connection pool for legitimate users. The attacker's cost: one MCP API credential and a slow read loop.

The defenses are: per-stream buffer limits (via highWaterMark), per-connection timeout that closes streams where the client has not read for > N seconds, and connection-level rate limiting that limits how many open streams a single credential can hold simultaneously:

// Middleware: close streams that haven't progressed in 30 seconds
function withStreamTimeout(stream, timeoutMs = 30_000) {
  let timer;
  const resetTimer = () => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      reader.cancel('stream timeout — client not reading');
    }, timeoutMs);
  };

  const reader = stream.getReader();
  resetTimer();

  return new ReadableStream({
    async pull(controller) {
      resetTimer();
      const { done, value } = await reader.read();
      if (done) { clearTimeout(timer); controller.close(); return; }
      controller.enqueue(value);
    },
    cancel() { clearTimeout(timer); reader.cancel('upstream cancel'); }
  });
}

Stream cancel without cleanup: the resource leak

When a client cancels a ReadableStream (by calling reader.cancel(), or by the browser garbage-collecting an unconsumed stream), the stream's cancel() method is called. If the stream's underlying source holds external resources — a database cursor, an open file handle, a running child process — and the cancel() implementation is missing or incomplete, those resources are leaked.

// Dangerous: missing cancel handler
new ReadableStream({
  async start(controller) {
    this.cursor = await db.openCursor(query); // cursor opened
  },
  async pull(controller) {
    const row = await this.cursor.next();
    if (row.done) { controller.close(); return; }
    controller.enqueue(row.value);
  }
  // NO cancel() handler — if client cancels, cursor leaks until GC
});

// Safe: always implement cancel
new ReadableStream({
  async start(controller) {
    this.cursor = await db.openCursor(query);
  },
  async pull(controller) {
    const row = await this.cursor.next();
    if (row.done) { controller.close(); return; }
    controller.enqueue(row.value);
  },
  async cancel(reason) {
    await this.cursor.close(); // always release cursor
  }
});

TransformStream pipelining: sanitize at every stage

MCP streaming responses often pipeline through TransformStream stages: raw output → JSON-encode → compress → encrypt → send. Each pipeline stage receives a chunk from the previous stage. If any stage passes chunks to a downstream stage without validating them, a malicious upstream (e.g., a compromised MCP tool that returns crafted output) can inject chunks designed to confuse the downstream stage — oversized chunks that trigger decompressor bomb behavior, JSON chunks with embedded newlines that break the JSON line-framing, or chunks that exploit parser bugs in the downstream transform.

// Safe pipeline with per-stage validation
const pipeline = readable
  .pipeThrough(new SizeGuardTransform({ maxChunkBytes: 65536 }))  // enforce per-chunk limit
  .pipeThrough(new JsonLineTransform())   // verify each chunk is valid JSON before forwarding
  .pipeThrough(new CompressionStream('gzip'))
  .pipeTo(response.body);

class SizeGuardTransform extends TransformStream {
  constructor({ maxChunkBytes }) {
    super({
      transform(chunk, controller) {
        if (chunk.byteLength > maxChunkBytes) {
          controller.error(new RangeError(`Chunk size ${chunk.byteLength} exceeds limit`));
          return;
        }
        controller.enqueue(chunk);
      }
    });
  }
}

Security comparison: Streams API patterns for MCP

PatternSecurity riskMitigation
ReadableStream without highWaterMark Unbounded server-side buffer — slow reader DoS Set highWaterMark in queuingStrategy; enforce per-connection stream limits
ReadableStream without cancel() Resource leak on client cancel — cursor, file handle, child process Always implement cancel(reason); release all held resources
Stream with no read timeout Stalled streams hold memory indefinitely — slow-loris variant Wrap stream with read-progress timeout; cancel after N seconds of no progress
TransformStream with no per-chunk validation Oversized or malformed chunks propagate to downstream stages Size guard + format validation in the first TransformStream in the pipeline
pipeTo without error handling Pipeline errors are silently swallowed — tool failure appears as empty response pipeTo(dest, { signal }).catch(handlePipelineError)

WritableStream abort handling

For MCP tools that accept streaming input (file uploads, large argument payloads), WritableStream abort handling follows the same pattern as AbortController: if the stream is aborted mid-write, the abort(reason) method on the underlying sink must release any partially-written resources. A partially-uploaded file must be deleted; a partially-written database transaction must be rolled back; a partially-acquired mutex must be released:

new WritableStream({
  async write(chunk, controller) {
    await this.file.write(chunk);
  },
  async abort(reason) {
    // Partial upload was aborted — delete the partial file
    await this.file.close();
    await fs.unlink(this.tempPath);
  },
  async close() {
    // Upload complete — move from temp to final path
    await this.file.close();
    await fs.rename(this.tempPath, this.finalPath);
  }
});

SkillAudit check: SkillAudit's static analysis detects ReadableStream constructors without a cancel method and WritableStream constructors without an abort method — common sources of resource leaks in MCP streaming implementations. It also checks for missing highWaterMark configuration that leaves streams unbounded. Audit your MCP server →

SkillAudit findings

High MCP streaming responses use ReadableStream without highWaterMark limit. Server buffers all chunks before the client reads, enabling slow-reader DoS: attacker holds multiple connections open with minimal read rate, exhausting server memory. −18 pts
High ReadableStream constructed with database cursor or file handle as underlying source, but no cancel() implementation. Client-canceled streams leave cursors open until GC — under load, exhausts the database connection pool. −16 pts
Medium No stream read-progress timeout. Stalled connections (client opened stream but stopped reading) hold server memory indefinitely. Slow-loris variant against MCP streaming endpoints. −12 pts
Medium TransformStream pipeline lacks per-chunk size validation. Oversized chunks from a compromised MCP tool propagate through compression and encryption stages, consuming CPU for compressor-bomb-scale inputs. −10 pts
Medium WritableStream for streaming tool input lacks abort() implementation. Aborted file uploads leave partial files in the temp directory; aborted transactions leave uncommitted rows. −8 pts

See also: MCP server AbortController security (abort propagation and resource cleanup) · MCP server fetch() security (streaming responses via Response.body)