Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 72 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,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();
});
});
Loading