From 7b48bd94fd2205ce2445ed0184c1351e27ecadcc Mon Sep 17 00:00:00 2001 From: errmakov Date: Fri, 11 Sep 2026 19:29:17 +0200 Subject: [PATCH] fix(stdio): strip a leading UTF-8 byte order mark before parsing a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadBuffer skips lines that fail JSON.parse so that stray debug output on stdout does not break the transport. A message that a peer prefixed with a UTF-8 BOM (as some Windows tools and shell redirections do) failed to parse for the same reason, so it was dropped without an error and the pending request waited for its timeout. Strip a leading U+FEFF from each line before parsing, which RFC 8259 §8.1 allows. A U+FEFF inside the message is left alone. Refs #2775 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P8hmy9JVNHqYDYEX5KBYye --- .changeset/stdio-byte-order-mark.md | 7 +++++ packages/client/test/client/stdio.test.ts | 25 +++++++++++++++++ packages/core-internal/src/shared/stdio.ts | 7 ++++- .../core-internal/test/shared/stdio.test.ts | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .changeset/stdio-byte-order-mark.md diff --git a/.changeset/stdio-byte-order-mark.md b/.changeset/stdio-byte-order-mark.md new file mode 100644 index 0000000000..dd5f1a7d15 --- /dev/null +++ b/.changeset/stdio-byte-order-mark.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Parse stdio messages that start with a UTF-8 byte order mark. `ReadBuffer` skips lines that fail to parse as JSON, so when a peer wrote a BOM before a message (as some Windows tools and shell redirections do), that message was dropped without an error and the pending request waited for its timeout. A leading U+FEFF is now stripped from each line before parsing, which RFC 8259 §8.1 allows. diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 315b8a2595..82f8dc7b88 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -122,6 +122,31 @@ test('should fire onerror and close when ReadBuffer overflows', async () => { await closed; }); +test('should read a message the server prefixes with a UTF-8 byte order mark', async () => { + const expected: JSONRPCMessage = { jsonrpc: '2.0', method: 'notifications/initialized' }; + const line = JSON.stringify(expected) + '\n'; + const client = new StdioClientTransport({ + command: 'node', + args: ['-e', `process.stdout.write(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(${JSON.stringify(line)})]))`] + }); + client.onerror = error => { + throw error; + }; + + const readMessages: JSONRPCMessage[] = []; + client.onmessage = message => { + readMessages.push(message); + }; + const closed = new Promise(resolve => { + client.onclose = () => resolve(); + }); + + await client.start(); + await closed; + + expect(readMessages).toEqual([expected]); +}); + test('_dispose releases the parent-side pipe handles even when a helper process holds the child stdio', async () => { // The rmcp-holding anatomy: the child exits, but a helper it spawned with // stdio: 'inherit' keeps the pipe write ends open. Awaiting 'exit' settles diff --git a/packages/core-internal/src/shared/stdio.ts b/packages/core-internal/src/shared/stdio.ts index 8bd794b87b..04d6a6d680 100644 --- a/packages/core-internal/src/shared/stdio.ts +++ b/packages/core-internal/src/shared/stdio.ts @@ -30,7 +30,12 @@ export class ReadBuffer { return null; } - const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); + // A UTF-8 byte order mark (written by some Windows tools and shell redirections) + // is not part of the JSON text; RFC 8259 §8.1 lets parsers ignore it. + const line = this._buffer + .toString('utf8', 0, index) + .replace(/\r$/, '') + .replace(/^\uFEFF/, ''); this._buffer = this._buffer.subarray(index + 1); try { diff --git a/packages/core-internal/test/shared/stdio.test.ts b/packages/core-internal/test/shared/stdio.test.ts index f8d27a4c1f..e9c3b0ef1c 100644 --- a/packages/core-internal/test/shared/stdio.test.ts +++ b/packages/core-internal/test/shared/stdio.test.ts @@ -114,6 +114,34 @@ describe('non-JSON line filtering', () => { }); }); +describe('byte order mark', () => { + const utf8ByteOrderMark = Buffer.from([0xef, 0xbb, 0xbf]); + + test('should parse a message preceded by a UTF-8 byte order mark', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.concat([utf8ByteOrderMark, Buffer.from(JSON.stringify(testMessage) + '\n')])); + + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should parse a CRLF-terminated message whose byte order mark is split across chunks', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(utf8ByteOrderMark.subarray(0, 1)); + readBuffer.append(Buffer.concat([utf8ByteOrderMark.subarray(1), Buffer.from(JSON.stringify(testMessage) + '\r\n')])); + + expect(readBuffer.readMessage()).toEqual(testMessage); + }); + + test('should keep a U+FEFF character that is part of the message', () => { + const readBuffer = new ReadBuffer(); + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { text: String.fromCharCode(0xfeff) + 'hello' } }; + readBuffer.append(Buffer.from(JSON.stringify(message) + '\n')); + + expect(readBuffer.readMessage()).toEqual(message); + }); +}); + describe('buffer size limit', () => { test('should throw when buffer exceeds default max size', () => { const readBuffer = new ReadBuffer();