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
5 changes: 5 additions & 0 deletions .changeset/fix-stdio-skip-non-json-lines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/sdk': patch
---

Skip non-JSON lines in the stdio `ReadBuffer` instead of surfacing a `SyntaxError` via `onerror` (e.g. a server logging "Shutting down..." to stdout on close). Valid JSON that fails schema validation still throws. Backport of #1762.
32 changes: 21 additions & 11 deletions src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,28 @@ export class ReadBuffer {
}

readMessage(): JSONRPCMessage | null {
if (!this._buffer) {
return null;
while (this._buffer) {
const index = this._buffer.indexOf('\n');
if (index === -1) {
return null;
}

const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
this._buffer = this._buffer.subarray(index + 1);

try {
return deserializeMessage(line);
} catch (error) {
// Skip non-JSON lines (e.g. debug output or shutdown logs written to stdout).
// Schema validation errors still throw so malformed-but-valid-JSON messages
// surface via onerror.
if (error instanceof SyntaxError) {
continue;
}
throw error;
}
}

const index = this._buffer.indexOf('\n');
if (index === -1) {
return null;
}

const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
this._buffer = this._buffer.subarray(index + 1);
return deserializeMessage(line);
return null;
}

clear(): void {
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/fixtures/stdio-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ if (process.env.E2E_GARBAGE_STDOUT === '1') {
process.stdout.write('GARBAGE LINE 1: not json\n');
process.stdout.write('GARBAGE LINE 2: {malformed json\n');
process.stdout.write('GARBAGE LINE 3: also not valid jsonrpc\n');
// Valid JSON but not a valid JSON-RPC message: non-JSON noise is skipped, but schema-invalid messages must still surface via onerror.
process.stdout.write('{"jsonrpc":"1.0","bogus":true}\n');
process.stdin.resume();
process.stdin.on('end', () => process.exit(0));
setTimeout(() => process.exit(1), 30_000);
Expand Down
15 changes: 10 additions & 5 deletions test/e2e/scenarios/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,10 @@ verifies('lifecycle:connect:onerror-pre-handshake', async (_args: TestArgs) => {
// prior to connect (Protocol wires transport.onerror before start() and
// before the initialize handshake).'
//
// Spawn the fixture with E2E_GARBAGE_STDOUT=1: it writes non-JSON garbage to
// stdout immediately (before the handshake) and exits. The garbage fails to
// parse, triggering transport.onerror. Assert that client.onerror fires and
// that connect() does not hang or falsely succeed.
// Spawn the fixture with E2E_GARBAGE_STDOUT=1: before the handshake it writes
// non-JSON noise (which is deliberately skipped) plus a schema-invalid JSON-RPC
// line, which must surface through transport.onerror. Assert that
// client.onerror fires and that connect() does not hang or falsely succeed.

const transport = new StdioClientTransport({
command: 'npx',
Expand Down Expand Up @@ -290,7 +290,12 @@ verifies('lifecycle:connect:onerror-pre-handshake', async (_args: TestArgs) => {

// At least one error should relate to JSON parsing of the garbage lines.
const hasJsonError = errors.some(
e => e.message.includes('JSON') || e.message.includes('parse') || e.message.includes('Unexpected token')
e =>
e.message.includes('JSON') ||
e.message.includes('parse') ||
e.message.includes('Unexpected token') ||
// non-JSON lines are now skipped; the schema-invalid line surfaces as a validation error naming the jsonrpc field
e.message.includes('"jsonrpc"')
);
expect(hasJsonError).toBe(true);
} finally {
Expand Down
80 changes: 80 additions & 0 deletions test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,86 @@ test('should be reusable after clearing', () => {
expect(readBuffer.readMessage()).toEqual(testMessage);
});

describe('non-JSON line filtering', () => {
test('should skip empty lines', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('\n\n' + JSON.stringify(testMessage) + '\n\n'));

expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should skip non-JSON lines before a valid message', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n'));

expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should skip non-JSON lines interleaved with multiple valid messages', () => {
const readBuffer = new ReadBuffer();
const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' };
const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' };

readBuffer.append(
Buffer.from(
'Debug line 1\n' +
JSON.stringify(message1) +
'\n' +
'Debug line 2\n' +
'Another non-JSON line\n' +
JSON.stringify(message2) +
'\n'
)
);

expect(readBuffer.readMessage()).toEqual(message1);
expect(readBuffer.readMessage()).toEqual(message2);
expect(readBuffer.readMessage()).toBeNull();
});

test('should preserve incomplete JSON at end of buffer until completed', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('{"jsonrpc": "2.0", "method": "test"'));
expect(readBuffer.readMessage()).toBeNull();

readBuffer.append(Buffer.from('}\n'));
expect(readBuffer.readMessage()).toEqual({ jsonrpc: '2.0', method: 'test' });
});

test('should skip lines with unbalanced braces', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('{incomplete\n' + 'incomplete}\n' + JSON.stringify(testMessage) + '\n'));

expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should skip lines that look like JSON but fail to parse', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('{invalidJson: true}\n' + JSON.stringify(testMessage) + '\n'));

expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should tolerate leading/trailing whitespace around valid JSON', () => {
const readBuffer = new ReadBuffer();
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
readBuffer.append(Buffer.from(' ' + JSON.stringify(message) + ' \n'));

expect(readBuffer.readMessage()).toEqual(message);
});

test('should still throw on valid JSON that fails schema validation', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from('{"not": "a jsonrpc message"}\n'));

expect(() => readBuffer.readMessage()).toThrow();
});
});

describe('buffer size limit', () => {
test('should throw when buffer exceeds default max size', () => {
const readBuffer = new ReadBuffer();
Expand Down
Loading