From 6049a3e1a3c3dcc531145eb14edc7ef9a4b64155 Mon Sep 17 00:00:00 2001 From: Constant Bourgois <20339167+constantbourgois@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:09:43 +0000 Subject: [PATCH 1/2] fix(stdio): skip non-JSON lines in ReadBuffer (v1.x backport of #1762) Claude-Session: https://claude.ai/code/session_01AXYSLfPNRScfVikFh2gpdA --- .changeset/fix-stdio-skip-non-json-lines.md | 5 ++ src/shared/stdio.ts | 32 ++++++--- test/shared/stdio.test.ts | 80 +++++++++++++++++++++ 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-stdio-skip-non-json-lines.md diff --git a/.changeset/fix-stdio-skip-non-json-lines.md b/.changeset/fix-stdio-skip-non-json-lines.md new file mode 100644 index 0000000000..65197b132a --- /dev/null +++ b/.changeset/fix-stdio-skip-non-json-lines.md @@ -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. diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts index 8f5478dfa8..78fb31a3d9 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -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 { diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index 87e666f7d0..06b08a67a7 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -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(); From cc28f017cc2461f3e000cdb3489e748a521e6386 Mon Sep 17 00:00:00 2001 From: Constant Bourgois <20339167+constantbourgois@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:29:14 +0000 Subject: [PATCH 2/2] test(e2e): expect schema-invalid line to surface onerror now that non-JSON lines are skipped Claude-Session: https://claude.ai/code/session_01AXYSLfPNRScfVikFh2gpdA --- test/e2e/fixtures/stdio-server.ts | 2 ++ test/e2e/scenarios/stdio.test.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/test/e2e/fixtures/stdio-server.ts b/test/e2e/fixtures/stdio-server.ts index 6b630f7195..5d9286b0cb 100644 --- a/test/e2e/fixtures/stdio-server.ts +++ b/test/e2e/fixtures/stdio-server.ts @@ -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); diff --git a/test/e2e/scenarios/stdio.test.ts b/test/e2e/scenarios/stdio.test.ts index c805c2d36b..fdf62de04c 100644 --- a/test/e2e/scenarios/stdio.test.ts +++ b/test/e2e/scenarios/stdio.test.ts @@ -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', @@ -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 {