Skip to content

Commit 330d4f8

Browse files
Merge branch 'main' into feature/v2-sdk-error-add-tests
2 parents 826b4b2 + f563440 commit 330d4f8

12 files changed

Lines changed: 287 additions & 42 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/codemod/src/bin/batchTest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const LOCAL_PACKAGE_DIRS: Record<string, string> = {
8787
'@modelcontextprotocol/client': path.join(SDK_ROOT, 'packages/client'),
8888
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
8989
'@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'),
90+
'@modelcontextprotocol/server-legacy': path.join(SDK_ROOT, 'packages/server-legacy'),
9091
'@modelcontextprotocol/express': path.join(SDK_ROOT, 'packages/middleware/express'),
9192
'@modelcontextprotocol/fastify': path.join(SDK_ROOT, 'packages/middleware/fastify'),
9293
'@modelcontextprotocol/hono': path.join(SDK_ROOT, 'packages/middleware/hono'),

packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
6262
StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport'
6363
},
6464
symbolTargetOverrides: {
65-
StreamableHTTPServerTransport: '@modelcontextprotocol/node'
65+
StreamableHTTPServerTransport: '@modelcontextprotocol/node',
66+
// The companion options type moved with the transport. @modelcontextprotocol/node
67+
// re-exports it under the same name (a backward-compat alias for
68+
// WebStandardStreamableHTTPServerTransportOptions), so route it there without renaming.
69+
StreamableHTTPServerTransportOptions: '@modelcontextprotocol/node'
6670
}
6771
},
6872
'@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js': {

packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,28 +101,15 @@ function handleReference(
101101
return rewriteCapturedSafeParse(safeParseCall, localName, typeName, sourceFile, diagnostics);
102102
}
103103

104-
diagnostics.push(
105-
actionRequired(
106-
sourceFile.getFilePath(),
107-
ref,
108-
`${localName}.safeParse() not available in v2. Use \`isSpecType.${typeName}(value)\` for boolean validation, ` +
109-
`or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` for full result.`
110-
)
111-
);
112-
return false;
104+
return rewriteUnsupportedSchemaCall(ref, safeParseCall, localName, typeName, 'safeParse', sourceFile, diagnostics);
113105
}
114106

115-
// Pattern: XSchema.parse(v) — diagnostic only
107+
// Pattern: XSchema.parse(v) — rewrite to the StandardSchema validate() primitive (or, when the
108+
// result is used, swap the identifier) so we never leave behind an import of a non-exported schema.
116109
if (isParsePattern(ref)) {
117-
diagnostics.push(
118-
actionRequired(
119-
sourceFile.getFilePath(),
120-
ref,
121-
`${localName}.parse() not available in v2. Use \`isSpecType.${typeName}(value)\` for validation, ` +
122-
`or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` and check for issues.`
123-
)
124-
);
125-
return false;
110+
const parseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
111+
const parseCall = parseAccess.getParent() as import('ts-morph').CallExpression;
112+
return rewriteUnsupportedSchemaCall(ref, parseCall, localName, typeName, 'parse', sourceFile, diagnostics);
126113
}
127114

128115
// Pattern: XSchema used as value (function arg, assignment, etc.)
@@ -327,6 +314,68 @@ function rewriteCapturedSafeParse(
327314
return true;
328315
}
329316

317+
/**
318+
* Handles spec-schema usages that have no behavior-preserving v2 equivalent: the Zod-only
319+
* methods `.parse()` and (uncaptured) `.safeParse()`. In v2 these schemas are StandardSchemaV1
320+
* values that are NOT named public exports, so leaving the original import in place produces an
321+
* unresolved-import error (e.g. `PromptSchema` is not exported by `@modelcontextprotocol/server`).
322+
*
323+
* - Result discarded (validation for side-effect only): rewrite `XSchema.parse(v)` →
324+
* `specTypeSchemas.T['~standard'].validate(v)` so the code compiles. NOTE: `validate()` does not
325+
* throw, so `.parse()`'s throw-on-invalid behavior is lost — flagged via an actionRequired comment.
326+
* - Result used: swap only the identifier to `specTypeSchemas.T` so the import resolves; the
327+
* `.parse()`/`.safeParse()` call and its result shape still need a manual fix (flagged).
328+
*
329+
* Either way the original (now non-exported) schema import is dropped by the caller's
330+
* removeUnusedImport, so no dangling import survives.
331+
*/
332+
function rewriteUnsupportedSchemaCall(
333+
ref: import('ts-morph').Node,
334+
callNode: import('ts-morph').CallExpression,
335+
localName: string,
336+
typeName: string,
337+
method: 'parse' | 'safeParse',
338+
sourceFile: SourceFile,
339+
diagnostics: Diagnostic[]
340+
): boolean {
341+
const resultDiscarded = Node.isExpressionStatement(callNode.getParent());
342+
343+
if (resultDiscarded) {
344+
const argText = callNode
345+
.getArguments()
346+
.map(a => a.getText())
347+
.join(', ');
348+
const semantics =
349+
method === 'parse'
350+
? 'validate() does NOT throw on invalid input (parse() did) — if you relied on that, add `if (result.issues) throw …`.'
351+
: 'the result shape changed from { success, data, error } to { value, issues }.';
352+
diagnostics.push(
353+
actionRequired(
354+
sourceFile.getFilePath(),
355+
callNode,
356+
`Rewrote ${localName}.${method}() to specTypeSchemas.${typeName}['~standard'].validate(): ` +
357+
`v2 spec schemas are StandardSchemaV1, not Zod. Note: ${semantics}`
358+
)
359+
);
360+
callNode.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`);
361+
ensureImport(sourceFile, 'specTypeSchemas');
362+
return true;
363+
}
364+
365+
diagnostics.push(
366+
actionRequired(
367+
sourceFile.getFilePath(),
368+
ref,
369+
`${localName}.${method}() is not available on v2 spec schemas (StandardSchemaV1, not Zod). ` +
370+
`Replaced ${localName} with specTypeSchemas.${typeName}; rewrite the .${method}(...) call using ` +
371+
`specTypeSchemas.${typeName}['~standard'].validate(...) (returns { value, issues }, does not throw).`
372+
)
373+
);
374+
ref.replaceWithText(`specTypeSchemas.${typeName}`);
375+
ensureImport(sourceFile, 'specTypeSchemas');
376+
return true;
377+
}
378+
330379
function ensureImport(sourceFile: SourceFile, symbol: string): void {
331380
const existingImport = sourceFile.getImportDeclarations().find(imp => {
332381
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;

packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,12 @@ describe('spec-schema-access transform', () => {
210210
expect(text).not.toContain('return result.value');
211211
});
212212

213-
it('falls back to diagnostic for non-captured safeParse (bare expression)', () => {
213+
it('rewrites non-captured safeParse (bare expression) to validate()', () => {
214214
const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.safeParse(data);`, ''].join('\n');
215-
const { result } = applyTransform(input);
216-
expect(result.changesCount).toBe(0);
215+
const { text, result } = applyTransform(input);
216+
expect(text).toContain("specTypeSchemas.Tool['~standard'].validate(data)");
217+
expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/);
218+
expect(result.changesCount).toBeGreaterThan(0);
217219
expect(result.diagnostics.length).toBe(1);
218220
});
219221
});
@@ -327,16 +329,24 @@ describe('spec-schema-access transform', () => {
327329
});
328330
});
329331

330-
describe('diagnostic only: .parse(v)', () => {
331-
it('emits diagnostic for parse usage', () => {
332+
describe('.parse(v)', () => {
333+
it('rewrites discarded parse() to the validate() primitive', () => {
334+
const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.parse(raw);`, ''].join('\n');
335+
const { text, result } = applyTransform(input);
336+
expect(text).toContain("specTypeSchemas.Tool['~standard'].validate(raw)");
337+
expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/);
338+
expect(result.changesCount).toBeGreaterThan(0);
339+
});
340+
341+
it('swaps the identifier (import stays resolvable) when the parse() result is used', () => {
332342
const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const tool = ToolSchema.parse(raw);`, ''].join(
333343
'\n'
334344
);
335345
const { text, result } = applyTransform(input);
336-
expect(text).toContain('ToolSchema.parse');
337-
expect(result.changesCount).toBe(0);
338-
expect(result.diagnostics.length).toBe(1);
339-
expect(result.diagnostics[0]!.message).toContain('isSpecType.Tool');
346+
expect(text).toContain('specTypeSchemas.Tool.parse(raw)');
347+
expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/);
348+
expect(result.changesCount).toBeGreaterThan(0);
349+
expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.Tool');
340350
});
341351
});
342352

@@ -392,7 +402,7 @@ describe('spec-schema-access transform', () => {
392402
expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/);
393403
});
394404

395-
it('keeps original schema import when some refs are diagnostic-only', () => {
405+
it('removes the schema import even when a ref falls back to a parse()/safeParse() rewrite', () => {
396406
const input = [
397407
`import { CallToolRequestSchema } from '@modelcontextprotocol/server';`,
398408
`const valid = CallToolRequestSchema.safeParse(data).success;`,
@@ -401,8 +411,8 @@ describe('spec-schema-access transform', () => {
401411
].join('\n');
402412
const { text } = applyTransform(input);
403413
expect(text).toContain('isSpecType.CallToolRequest(data)');
404-
expect(text).toContain('CallToolRequestSchema.parse');
405-
expect(text).toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/);
414+
expect(text).toContain('specTypeSchemas.CallToolRequest.parse(data)');
415+
expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/);
406416
});
407417

408418
it('removes schema specifier from import that also has other symbols', () => {

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+
});

0 commit comments

Comments
 (0)