Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/stdio-max-message-bytes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Add `maxMessageBytes` option to the stdio transports and make the stdio read buffer amortized O(1) per byte

A stdio peer that writes a very large amount of data without a newline (accidental binary output, runaway log line, or a malicious server) previously grew the receiving process's memory without bound, and each incoming chunk re-copied the entire buffered backlog (`Buffer.concat` per chunk). There was no public way to bound or replace the read buffer, so integrators who had built flood protection on v1 transport internals had nothing to migrate to.

`StdioClientTransport` and `StdioServerTransport` now accept an optional `maxMessageBytes`. When a single message exceeds it, the data is dropped, an `SdkError` with the new code `SdkErrorCode.MessageTooLarge` is reported via `onerror`, and the transport recovers at the next newline boundary. The default remains unlimited. The read buffer also now grows geometrically with read/scan offsets instead of concatenating on every chunk.
4 changes: 3 additions & 1 deletion docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient)
```

**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all
Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11).
Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). The stdio transports additionally accept a new optional `maxMessageBytes` option in v2 (see the stdio transport notes).

## 6. McpServer API Changes

Expand Down Expand Up @@ -507,6 +507,8 @@ NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + infe

`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.

Stdio transports: non-JSON lines on the stream are now skipped silently (v1 surfaced a `SyntaxError` via `onerror`); valid-JSON messages failing schema validation still reach `onerror`. Both stdio transports accept an optional `maxMessageBytes` — oversized messages are dropped and reported via `onerror` as `SdkError` with code `SdkErrorCode.MessageTooLarge` (default: unlimited). Use it to replace any v1-era flood protection built on transport internals.

## 14. Runtime-Specific JSON Schema Validators (Enhancement)

The SDK now auto-selects the appropriate JSON Schema validator based on runtime:
Expand Down
25 changes: 23 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
```

### Stdio transports: non-JSON lines are now skipped silently

In v1, a non-JSON line on the stdio stream (for example, debug output from a hot-reload
tool writing to stdout) surfaced as a `SyntaxError` through the transport's `onerror`
callback. In v2, the stdio read buffer silently skips lines that are not valid JSON and
continues with the next line; only valid-JSON messages that fail schema validation still
reach `onerror`. If you relied on `onerror` to detect a misbehaving server that writes
noise to stdout, that signal no longer fires for non-JSON lines.

Relatedly, both stdio transports now accept an optional `maxMessageBytes` setting that
bounds how large a single message may grow before it is dropped and reported via
`onerror` (`SdkError` with code `SdkErrorCode.MessageTooLarge`). v1 had no built-in
protection against a peer flooding the stream with unbounded data on a single line; if
you implemented such protection against v1 transport internals, migrate to this option.

```typescript
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const transport = new StdioClientTransport({ command: 'my-server' }, { maxMessageBytes: 4 * 1024 * 1024 });
```

Check warning on line 164 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

SdkErrorCode.MessageTooLarge missing from the SdkErrorCode enum listings in both migration guides

The new `SdkErrorCode.MessageTooLarge` member referenced in this section is missing from the authoritative `SdkErrorCode` enum listings later in this same guide (the "New `SdkErrorCode` enum" table, ~lines 746-761) and in `docs/migration-SKILL.md` (the "New `SdkErrorCode` enum values:" bullet list, ~lines 129-142), which were complete enumerations before this PR. Adding one table row and one bullet keeps both lists in sync with the enum and tells SKILL-file consumers the code's string value (`'M
Comment thread
claude[bot] marked this conversation as resolved.
Comment on lines +144 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new SdkErrorCode.MessageTooLarge member referenced in this section is missing from the authoritative SdkErrorCode enum listings later in this same guide (the "New SdkErrorCode enum" table, ~lines 746-761) and in docs/migration-SKILL.md (the "New SdkErrorCode enum values:" bullet list, ~lines 129-142), which were complete enumerations before this PR. Adding one table row and one bullet keeps both lists in sync with the enum and tells SKILL-file consumers the code's string value ('MESSAGE_TOO_LARGE').

Extended reasoning...

What the issue is. This PR adds a new enum member, SdkErrorCode.MessageTooLarge = 'MESSAGE_TOO_LARGE' (packages/core/src/errors/sdkErrors.ts:30), and the new stdio sections in both migration guides tell migrators to branch on exactly that code (docs/migration.md:155 and section 13 of docs/migration-SKILL.md). However, the authoritative enumerations of SdkErrorCode in those same documents were not updated: the "New SdkErrorCode enum" table in docs/migration.md:746-761 ("The new SdkErrorCode enum contains string-valued codes for local SDK errors:") and the "New SdkErrorCode enum values:" bullet list in docs/migration-SKILL.md:129-142 both still list only the 14 pre-PR members.

Why this is introduced by this PR. Before this change, both lists matched the enum exactly — 14 members in the enum, 14 entries in each list — so they read as complete enumerations. After this change the enum has 15 members and the lists are stale by precisely the member the PR adds. Within the same documents the PR edits, one section instructs users to handle SdkErrorCode.MessageTooLarge while the section that purports to enumerate every SdkErrorCode value omits it, leaving the guides internally inconsistent.

Step-by-step proof.

  1. packages/core/src/errors/sdkErrors.ts in this PR adds MessageTooLarge = 'MESSAGE_TOO_LARGE' between SendFailed and InvalidResult, bringing the enum to 15 members.
  2. The new stdio section added by this PR at docs/migration.md:144-164 says oversized messages are "reported via onerror (SdkError with code SdkErrorCode.MessageTooLarge)"; docs/migration-SKILL.md section 13 says the same.
  3. The "New SdkErrorCode enum" table at docs/migration.md:746-761 lists NotConnected, AlreadyConnected, NotInitialized, CapabilityNotSupported, RequestTimeout, ConnectionClosed, SendFailed, InvalidResult, and the six ClientHttp* codes — every member of the enum except MessageTooLarge.
  4. The "New SdkErrorCode enum values:" bullet list at docs/migration-SKILL.md:129-142 has the same complete-minus-one set, and is the only place in the SKILL file that records the string values of the codes — so an LLM consuming that file as the source of truth for the enum will not learn that 'MESSAGE_TOO_LARGE' exists or what string it maps to.

Why nothing else prevents it. Both lists are hand-maintained prose with no check against the actual enum, so the only safeguard is updating them in the same change that extends the enum — which this PR does for the "Unchanged APIs" sections (already qualified for maxMessageBytes) but not for the enum listings. This matches the repo guideline that both migration docs stay in sync with behavior/API changes and that related additions be grouped into the existing sections rather than only mentioned in new prose.

Impact. Low — the new code is documented in the new stdio sections of both guides, so nobody is misled about behavior; this is purely a completeness/consistency gap in the reference lists. It mainly affects readers (or LLMs) who treat those lists as the canonical enumeration of SdkErrorCode and its string values. It is also distinct from the previously posted "Unchanged APIs list is stale" comment, which concerns a different section that this PR did update.

How to fix. Add one row to the table in docs/migration.md (e.g. | \SdkErrorCode.MessageTooLarge` | A single inbound message exceeded the configured maximum size |, ideally next to the other transport codes after SendFailed) and one bullet to docs/migration-SKILL.md (- `SdkErrorCode.MessageTooLarge` = `'MESSAGE_TOO_LARGE'``) in the same position.

### Server auth split

Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`.
Expand Down Expand Up @@ -984,8 +1005,8 @@
- `Client` constructor and most client methods (`connect`, `listTools`, `listPrompts`, `listResources`, `readResource`, etc.) — note: `callTool()` signature changed (schema parameter removed)
- `McpServer` constructor, `server.connect(transport)`, `server.close()`
- `Server` (low-level) constructor and all methods
- `StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport` constructors and options
- `StdioServerTransport` constructor and options
- `StreamableHTTPClientTransport` and `SSEClientTransport` constructors and options
- `StdioClientTransport` and `StdioServerTransport` constructors — note: both accept a new optional `maxMessageBytes` option in v2 (see the stdio transport notes above)
- All Zod schemas and type definitions from `types.ts` (except the aliases listed above)
- Tool, prompt, and resource callback return types

Expand Down
21 changes: 19 additions & 2 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core';
import spawn from 'cross-spawn';

export type StdioClientTransportOptions = {
/**
* Maximum size, in bytes, that a single inbound message may occupy.
*
* Protects against a misbehaving server flooding the client with an unbounded
* amount of data on a single line (e.g. accidental binary or log output on
* stdout), which would otherwise grow client memory without limit. When a
* message exceeds this size it is dropped, an {@linkcode SdkError} with code
* `SdkErrorCode.MessageTooLarge` is reported via `onerror`, and the transport
* recovers at the next newline boundary.
*
* Defaults to undefined (no limit), matching previous behavior.
*/
maxMessageBytes?: number;
};

export type StdioServerParameters = {
/**
* The executable to run to start the server.
Expand Down Expand Up @@ -92,16 +108,17 @@ export function getDefaultEnvironment(): Record<string, string> {
*/
export class StdioClientTransport implements Transport {
private _process?: ChildProcess;
private _readBuffer: ReadBuffer = new ReadBuffer();
private _readBuffer: ReadBuffer;
private _serverParams: StdioServerParameters;
private _stderrStream: PassThrough | null = null;

onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;

constructor(server: StdioServerParameters) {
constructor(server: StdioServerParameters, options?: StdioClientTransportOptions) {
this._serverParams = server;
this._readBuffer = new ReadBuffer({ maxMessageBytes: options?.maxMessageBytes });
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
this._stderrStream = new PassThrough();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
// Cloudflare Workers targets does not pull in `node:child_process`, `node:stream`, or `cross-spawn`. Import
// from `@modelcontextprotocol/client/stdio` only in process-spawning runtimes (Node.js, Bun, Deno).

export type { StdioServerParameters } from './client/stdio.js';
export type { StdioClientTransportOptions, StdioServerParameters } from './client/stdio.js';
export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from './client/stdio.js';
82 changes: 82 additions & 0 deletions packages/client/test/client/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core';

import type { StdioServerParameters } from '../../src/client/stdio.js';
import { StdioClientTransport } from '../../src/client/stdio.js';
Expand Down Expand Up @@ -77,3 +78,84 @@ test('should return child process pid', async () => {
await client.close();
expect(client.pid).toBeNull();
});

test('should surface MessageTooLarge via onerror and keep running when maxMessageBytes is exceeded', async () => {
// `tee`/`more` echo stdin back on stdout, so an oversized outbound message
// becomes an oversized inbound message.
const client = new StdioClientTransport(serverParameters, { maxMessageBytes: 1024 });

const errors: Error[] = [];
const oversizedReported = new Promise<void>(resolve => {
client.onerror = error => {
errors.push(error);
resolve();
};
});

const messages: JSONRPCMessage[] = [];
const smallMessageEchoed = new Promise<void>(resolve => {
client.onmessage = message => {
messages.push(message);
resolve();
};
});

await client.start();

const oversized: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'oversized',
params: { payload: 'x'.repeat(10_000) }
};
await client.send(oversized);
await oversizedReported;

expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(SdkError);
expect((errors[0] as SdkError).code).toBe(SdkErrorCode.MessageTooLarge);

// The transport recovers: a small message still round-trips.
const small: JSONRPCMessage = { jsonrpc: '2.0', method: 'small' };
await client.send(small);
await smallMessageEchoed;
expect(messages).toEqual([small]);

await client.close();
});

test('should recover when a child floods stdout without a newline', async () => {
// A misbehaving server that writes a large amount of data with no message
// boundary, then a valid message: the limit must trip while the flood is
// still incomplete, and the transport must recover at the newline.
const childScript = [
"process.stdout.write('x'.repeat(1_000_000));",
"process.stdout.write('\\n');",
"process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'after-flood' }) + '\\n');",
'setInterval(() => {}, 1 << 30);'
].join(' ');

const client = new StdioClientTransport({ command: process.execPath, args: ['-e', childScript] }, { maxMessageBytes: 65_536 });

const errors: Error[] = [];
client.onerror = error => {
errors.push(error);
};

const messages: JSONRPCMessage[] = [];
const messageAfterFlood = new Promise<void>(resolve => {
client.onmessage = message => {
messages.push(message);
resolve();
};
});

await client.start();
await messageAfterFlood;

expect(messages).toEqual([{ jsonrpc: '2.0', method: 'after-flood' }]);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(SdkError);
expect((errors[0] as SdkError).code).toBe(SdkErrorCode.MessageTooLarge);

await client.close();
});
2 changes: 2 additions & 0 deletions packages/core/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export enum SdkErrorCode {
ConnectionClosed = 'CONNECTION_CLOSED',
/** Failed to send message */
SendFailed = 'SEND_FAILED',
/** A single inbound message exceeded the configured maximum size */
MessageTooLarge = 'MESSAGE_TOO_LARGE',
/** Response result failed local schema validation */
InvalidResult = 'INVALID_RESULT',

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js';

// stdio message framing utilities (for custom transport authors)
export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js';
export type { ReadBufferOptions } from '../../shared/stdio.js';

// Transport types (NOT normalizeHeaders)
export type { FetchLike, Transport, TransportSendOptions } from '../../shared/transport.js';
Expand Down
Loading
Loading