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();