Skip to content
Merged
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/stdio-skip-non-json.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 21 additions & 11 deletions packages/core/src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions packages/core/test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading