Skip to content

Commit 1b53a41

Browse files
1 parent 8d55531 commit 1b53a41

8 files changed

Lines changed: 191 additions & 10 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
'@modelcontextprotocol/client': patch
4+
'@modelcontextprotocol/server': patch
5+
---
6+
7+
Add a configurable `maxBufferSize` (default 10 MB) to the stdio transports. When a single message would push the read buffer past the limit, the transport now emits an `onerror` and closes instead of growing the buffer unbounded. Configure via `new StdioClientTransport({ ..., maxBufferSize })` or `new StdioServerTransport(stdin, stdout, { maxBufferSize })`. The default is exported from `@modelcontextprotocol/core` as `STDIO_DEFAULT_MAX_BUFFER_SIZE`.

packages/client/src/client/stdio.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export type StdioServerParameters = {
3838
* If not specified, the current working directory will be inherited.
3939
*/
4040
cwd?: string;
41+
42+
/**
43+
* Maximum size of the read buffer in bytes. If a single message exceeds
44+
* this size the transport will emit an error and close.
45+
*
46+
* Defaults to 10 MB.
47+
*/
48+
maxBufferSize?: number;
4149
};
4250

4351
/**
@@ -92,7 +100,7 @@ export function getDefaultEnvironment(): Record<string, string> {
92100
*/
93101
export class StdioClientTransport implements Transport {
94102
private _process?: ChildProcess;
95-
private _readBuffer: ReadBuffer = new ReadBuffer();
103+
private _readBuffer: ReadBuffer;
96104
private _serverParams: StdioServerParameters;
97105
private _stderrStream: PassThrough | null = null;
98106

@@ -102,6 +110,7 @@ export class StdioClientTransport implements Transport {
102110

103111
constructor(server: StdioServerParameters) {
104112
this._serverParams = server;
113+
this._readBuffer = new ReadBuffer({ maxBufferSize: server.maxBufferSize });
105114
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
106115
this._stderrStream = new PassThrough();
107116
}
@@ -149,8 +158,13 @@ export class StdioClientTransport implements Transport {
149158
});
150159

151160
this._process.stdout?.on('data', chunk => {
152-
this._readBuffer.append(chunk);
153-
this.processReadBuffer();
161+
try {
162+
this._readBuffer.append(chunk);
163+
this.processReadBuffer();
164+
} catch (error) {
165+
this.onerror?.(error as Error);
166+
this.close().catch(() => {});
167+
}
154168
});
155169

156170
this._process.stdout?.on('error', error => {

packages/client/test/client/stdio.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,44 @@ test('should return child process pid', async () => {
7777
await client.close();
7878
expect(client.pid).toBeNull();
7979
});
80+
81+
test('should respect custom maxBufferSize option', async () => {
82+
const client = new StdioClientTransport({
83+
command: 'node',
84+
args: ['-e', 'process.stdout.write(Buffer.alloc(200, 0x41))'],
85+
maxBufferSize: 100
86+
});
87+
88+
const errorReceived = new Promise<Error>(resolve => {
89+
client.onerror = resolve;
90+
});
91+
const closed = new Promise<void>(resolve => {
92+
client.onclose = () => resolve();
93+
});
94+
95+
await client.start();
96+
97+
const error = await errorReceived;
98+
expect(error.message).toMatch(/ReadBuffer exceeded maximum size/);
99+
await closed;
100+
});
101+
102+
test('should fire onerror and close when ReadBuffer overflows', async () => {
103+
const client = new StdioClientTransport({
104+
command: 'node',
105+
args: ['-e', 'process.stdout.write(Buffer.alloc(11 * 1024 * 1024, 0x41))']
106+
});
107+
108+
const errorReceived = new Promise<Error>(resolve => {
109+
client.onerror = resolve;
110+
});
111+
const closed = new Promise<void>(resolve => {
112+
client.onclose = () => resolve();
113+
});
114+
115+
await client.start();
116+
117+
const error = await errorReceived;
118+
expect(error.message).toMatch(/ReadBuffer exceeded maximum size/);
119+
await closed;
120+
});

packages/core/src/exports/public/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export type {
5353
export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js';
5454

5555
// stdio message framing utilities (for custom transport authors)
56-
export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js';
56+
export { deserializeMessage, ReadBuffer, serializeMessage, STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../shared/stdio.js';
5757

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

packages/core/src/shared/stdio.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import type { JSONRPCMessage } from '../types/index.js';
22
import { JSONRPCMessageSchema } from '../types/index.js';
33

4+
export const STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
5+
46
/**
57
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
68
*/
79
export class ReadBuffer {
810
private _buffer?: Buffer;
11+
private _maxBufferSize: number;
12+
13+
constructor(options?: { maxBufferSize?: number }) {
14+
this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
15+
}
916

1017
append(chunk: Buffer): void {
18+
const newSize = (this._buffer?.length ?? 0) + chunk.length;
19+
if (newSize > this._maxBufferSize) {
20+
this.clear();
21+
throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
22+
}
1123
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
1224
}
1325

packages/core/test/shared/stdio.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReadBuffer } from '../../src/shared/stdio.js';
1+
import { ReadBuffer, STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../src/shared/stdio.js';
22
import type { JSONRPCMessage } from '../../src/types/index.js';
33

44
const testMessage: JSONRPCMessage = {
@@ -113,3 +113,46 @@ describe('non-JSON line filtering', () => {
113113
expect(() => readBuffer.readMessage()).toThrow();
114114
});
115115
});
116+
117+
describe('buffer size limit', () => {
118+
test('should throw when buffer exceeds default max size', () => {
119+
const readBuffer = new ReadBuffer();
120+
const chunkSize = 1024 * 1024; // 1 MB
121+
const chunk = Buffer.alloc(chunkSize);
122+
const chunksToFill = Math.floor(STDIO_DEFAULT_MAX_BUFFER_SIZE / chunkSize);
123+
for (let i = 0; i < chunksToFill; i++) {
124+
readBuffer.append(chunk);
125+
}
126+
expect(() => readBuffer.append(chunk)).toThrow(/ReadBuffer exceeded maximum size/);
127+
});
128+
129+
test('should throw when buffer exceeds custom max size', () => {
130+
const readBuffer = new ReadBuffer({ maxBufferSize: 100 });
131+
readBuffer.append(Buffer.alloc(50));
132+
expect(() => readBuffer.append(Buffer.alloc(51))).toThrow(/ReadBuffer exceeded maximum size/);
133+
});
134+
135+
test('should clear buffer before throwing on overflow', () => {
136+
const readBuffer = new ReadBuffer({ maxBufferSize: 100 });
137+
readBuffer.append(Buffer.alloc(50));
138+
expect(() => readBuffer.append(Buffer.alloc(51))).toThrow();
139+
140+
// Buffer should be cleared — can append again
141+
readBuffer.append(Buffer.alloc(50));
142+
// And read messages normally
143+
expect(readBuffer.readMessage()).toBeNull();
144+
});
145+
146+
test('should allow appending up to exactly the max size', () => {
147+
const readBuffer = new ReadBuffer({ maxBufferSize: 100 });
148+
// Should not throw — exactly at limit
149+
expect(() => readBuffer.append(Buffer.alloc(100))).not.toThrow();
150+
});
151+
152+
test('should work with no options (backwards compatible)', () => {
153+
const readBuffer = new ReadBuffer();
154+
// Small append should always work
155+
readBuffer.append(Buffer.from(JSON.stringify({ jsonrpc: '2.0', method: 'ping' }) + '\n'));
156+
expect(readBuffer.readMessage()).not.toBeNull();
157+
});
158+
});

packages/server/src/server/stdio.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,39 @@ import { process } from '@modelcontextprotocol/server/_shims';
1717
* ```
1818
*/
1919
export class StdioServerTransport implements Transport {
20-
private _readBuffer: ReadBuffer = new ReadBuffer();
20+
private _readBuffer: ReadBuffer;
2121
private _started = false;
2222
private _closed = false;
2323

2424
constructor(
2525
private _stdin: Readable = process.stdin,
26-
private _stdout: Writable = process.stdout
27-
) {}
26+
private _stdout: Writable = process.stdout,
27+
options?: {
28+
/**
29+
* Maximum size of the read buffer in bytes. If a single message exceeds
30+
* this size the transport will emit an error and close.
31+
*
32+
* Defaults to 10 MB.
33+
*/
34+
maxBufferSize?: number;
35+
}
36+
) {
37+
this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
38+
}
2839

2940
onclose?: () => void;
3041
onerror?: (error: Error) => void;
3142
onmessage?: (message: JSONRPCMessage) => void;
3243

3344
// Arrow functions to bind `this` properly, while maintaining function identity.
3445
_ondata = (chunk: Buffer) => {
35-
this._readBuffer.append(chunk);
36-
this.processReadBuffer();
46+
try {
47+
this._readBuffer.append(chunk);
48+
this.processReadBuffer();
49+
} catch (error) {
50+
this.onerror?.(error as Error);
51+
this.close().catch(() => {});
52+
}
3753
};
3854
_onerror = (error: Error) => {
3955
this.onerror?.(error);

packages/server/test/server/stdio.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,51 @@ test('should fire onerror before onclose on stdout error', async () => {
179179

180180
expect(events).toEqual(['error', 'close']);
181181
});
182+
183+
test('should respect custom maxBufferSize option', async () => {
184+
const server = new StdioServerTransport(input, output, { maxBufferSize: 100 });
185+
186+
let receivedError: Error | undefined;
187+
server.onerror = err => {
188+
receivedError = err;
189+
};
190+
let closeCount = 0;
191+
server.onclose = () => {
192+
closeCount++;
193+
};
194+
195+
await server.start();
196+
197+
// Push 101 bytes without a newline — exceeds the 100-byte limit
198+
input.push(Buffer.alloc(101, 0x41));
199+
200+
await new Promise(resolve => setTimeout(resolve, 10));
201+
202+
expect(receivedError?.message).toMatch(/ReadBuffer exceeded maximum size/);
203+
expect(closeCount).toBe(1);
204+
});
205+
206+
test('should fire onerror and close when ReadBuffer overflows', async () => {
207+
const server = new StdioServerTransport(input, output);
208+
209+
let receivedError: Error | undefined;
210+
server.onerror = err => {
211+
receivedError = err;
212+
};
213+
let closeCount = 0;
214+
server.onclose = () => {
215+
closeCount++;
216+
};
217+
218+
await server.start();
219+
220+
// Push data exceeding the default 10 MB limit without a newline
221+
const chunk = Buffer.alloc(11 * 1024 * 1024, 0x41);
222+
input.push(chunk);
223+
224+
// Allow the close() promise to settle
225+
await new Promise(resolve => setTimeout(resolve, 10));
226+
227+
expect(receivedError?.message).toMatch(/ReadBuffer exceeded maximum size/);
228+
expect(closeCount).toBe(1);
229+
});

0 commit comments

Comments
 (0)