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..65d1de0ea 100644 --- a/packages/core/test/shared/stdio.test.ts +++ b/packages/core/test/shared/stdio.test.ts @@ -33,3 +33,83 @@ test('should be reusable after clearing', () => { readBuffer.append(Buffer.from('\n')); 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(); + }); +});