Skip to content

Commit 64897f7

Browse files
felixweinbergerSahar Shemesh
andauthored
fix(stdio): skip non-JSON lines in ReadBuffer (#1762)
Co-authored-by: Sahar Shemesh <sahar.shemesh@zoominfo.com>
1 parent a2e5037 commit 64897f7

File tree

3 files changed

+106
-11
lines changed

3 files changed

+106
-11
lines changed

.changeset/stdio-skip-non-json.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
---
4+
5+
`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.

packages/core/src/shared/stdio.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,28 @@ export class ReadBuffer {
1212
}
1313

1414
readMessage(): JSONRPCMessage | null {
15-
if (!this._buffer) {
16-
return null;
15+
while (this._buffer) {
16+
const index = this._buffer.indexOf('\n');
17+
if (index === -1) {
18+
return null;
19+
}
20+
21+
const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
22+
this._buffer = this._buffer.subarray(index + 1);
23+
24+
try {
25+
return deserializeMessage(line);
26+
} catch (error) {
27+
// Skip non-JSON lines (e.g., debug output from hot-reload tools like
28+
// tsx or nodemon that write to stdout). Schema validation errors still
29+
// throw so malformed-but-valid-JSON messages surface via onerror.
30+
if (error instanceof SyntaxError) {
31+
continue;
32+
}
33+
throw error;
34+
}
1735
}
18-
19-
const index = this._buffer.indexOf('\n');
20-
if (index === -1) {
21-
return null;
22-
}
23-
24-
const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
25-
this._buffer = this._buffer.subarray(index + 1);
26-
return deserializeMessage(line);
36+
return null;
2737
}
2838

2939
clear(): void {

packages/core/test/shared/stdio.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,83 @@ test('should be reusable after clearing', () => {
3333
readBuffer.append(Buffer.from('\n'));
3434
expect(readBuffer.readMessage()).toEqual(testMessage);
3535
});
36+
37+
describe('non-JSON line filtering', () => {
38+
test('should skip empty lines', () => {
39+
const readBuffer = new ReadBuffer();
40+
readBuffer.append(Buffer.from('\n\n' + JSON.stringify(testMessage) + '\n\n'));
41+
42+
expect(readBuffer.readMessage()).toEqual(testMessage);
43+
expect(readBuffer.readMessage()).toBeNull();
44+
});
45+
46+
test('should skip non-JSON lines before a valid message', () => {
47+
const readBuffer = new ReadBuffer();
48+
readBuffer.append(Buffer.from('Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n'));
49+
50+
expect(readBuffer.readMessage()).toEqual(testMessage);
51+
expect(readBuffer.readMessage()).toBeNull();
52+
});
53+
54+
test('should skip non-JSON lines interleaved with multiple valid messages', () => {
55+
const readBuffer = new ReadBuffer();
56+
const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' };
57+
const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' };
58+
59+
readBuffer.append(
60+
Buffer.from(
61+
'Debug line 1\n' +
62+
JSON.stringify(message1) +
63+
'\n' +
64+
'Debug line 2\n' +
65+
'Another non-JSON line\n' +
66+
JSON.stringify(message2) +
67+
'\n'
68+
)
69+
);
70+
71+
expect(readBuffer.readMessage()).toEqual(message1);
72+
expect(readBuffer.readMessage()).toEqual(message2);
73+
expect(readBuffer.readMessage()).toBeNull();
74+
});
75+
76+
test('should preserve incomplete JSON at end of buffer until completed', () => {
77+
const readBuffer = new ReadBuffer();
78+
readBuffer.append(Buffer.from('{"jsonrpc": "2.0", "method": "test"'));
79+
expect(readBuffer.readMessage()).toBeNull();
80+
81+
readBuffer.append(Buffer.from('}\n'));
82+
expect(readBuffer.readMessage()).toEqual({ jsonrpc: '2.0', method: 'test' });
83+
});
84+
85+
test('should skip lines with unbalanced braces', () => {
86+
const readBuffer = new ReadBuffer();
87+
readBuffer.append(Buffer.from('{incomplete\n' + 'incomplete}\n' + JSON.stringify(testMessage) + '\n'));
88+
89+
expect(readBuffer.readMessage()).toEqual(testMessage);
90+
expect(readBuffer.readMessage()).toBeNull();
91+
});
92+
93+
test('should skip lines that look like JSON but fail to parse', () => {
94+
const readBuffer = new ReadBuffer();
95+
readBuffer.append(Buffer.from('{invalidJson: true}\n' + JSON.stringify(testMessage) + '\n'));
96+
97+
expect(readBuffer.readMessage()).toEqual(testMessage);
98+
expect(readBuffer.readMessage()).toBeNull();
99+
});
100+
101+
test('should tolerate leading/trailing whitespace around valid JSON', () => {
102+
const readBuffer = new ReadBuffer();
103+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
104+
readBuffer.append(Buffer.from(' ' + JSON.stringify(message) + ' \n'));
105+
106+
expect(readBuffer.readMessage()).toEqual(message);
107+
});
108+
109+
test('should still throw on valid JSON that fails schema validation', () => {
110+
const readBuffer = new ReadBuffer();
111+
readBuffer.append(Buffer.from('{"not": "a jsonrpc message"}\n'));
112+
113+
expect(() => readBuffer.readMessage()).toThrow();
114+
});
115+
});

0 commit comments

Comments
 (0)