From 55a5dc40e60581dddb4e64ba6d6a7f04e66f2f01 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 25 Mar 2026 20:48:36 +0000 Subject: [PATCH 1/2] fix(stdio): skip non-JSON lines in ReadBuffer ReadBuffer.readMessage() now loops past lines that throw SyntaxError (e.g., debug output from tsx/nodemon writing to stdout) instead of propagating them to onerror. Lines that parse as JSON but fail JSONRPC schema validation still throw, so genuinely malformed messages still surface. Keeps the existing Buffer-based architecture to preserve UTF-8 correctness across chunk boundaries and the deserializeMessage(string) public signature. Fixes #700 Closes #1225 Co-authored-by: Sahar Shemesh --- .changeset/stdio-skip-non-json.md | 5 ++ packages/core/src/shared/stdio.ts | 32 +++++++---- packages/core/test/shared/stdio.test.ts | 72 +++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 .changeset/stdio-skip-non-json.md diff --git a/.changeset/stdio-skip-non-json.md b/.changeset/stdio-skip-non-json.md new file mode 100644 index 000000000..d20b740c9 --- /dev/null +++ b/.changeset/stdio-skip-non-json.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +`ReadBuffer.readMessage()` now silently skips non-JSON lines instead of throwing `SyntaxError`. This prevents noisy `onerror` callbacks when hot-reload tools (tsx, nodemon) write debug output like "Gracefully restarting..." to stdout. Lines that parse as JSON but fail JSONRPC schema validation still throw. diff --git a/packages/core/src/shared/stdio.ts b/packages/core/src/shared/stdio.ts index 773860770..7283a5ef9 100644 --- a/packages/core/src/shared/stdio.ts +++ b/packages/core/src/shared/stdio.ts @@ -12,18 +12,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 from hot-reload tools like + // tsx or nodemon that write 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/packages/core/test/shared/stdio.test.ts b/packages/core/test/shared/stdio.test.ts index 7e880aa57..6e9659e87 100644 --- a/packages/core/test/shared/stdio.test.ts +++ b/packages/core/test/shared/stdio.test.ts @@ -33,3 +33,75 @@ test('should be reusable after clearing', () => { readBuffer.append(Buffer.from('\n')); expect(readBuffer.readMessage()).toEqual(testMessage); }); + +describe('non-JSON line filtering', () => { + 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(); + }); +}); From 05fadb9c88441957b5ad9da19f4159e3348191fc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 26 Mar 2026 13:49:07 +0000 Subject: [PATCH 2/2] test(stdio): add coverage for empty lines in ReadBuffer --- packages/core/test/shared/stdio.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/test/shared/stdio.test.ts b/packages/core/test/shared/stdio.test.ts index 6e9659e87..65d1de0ea 100644 --- a/packages/core/test/shared/stdio.test.ts +++ b/packages/core/test/shared/stdio.test.ts @@ -35,6 +35,14 @@ test('should be reusable after clearing', () => { }); 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'));