diff --git a/.changeset/stdio-byte-order-mark.md b/.changeset/stdio-byte-order-mark.md new file mode 100644 index 0000000000..882e59de52 --- /dev/null +++ b/.changeset/stdio-byte-order-mark.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Parse stdio messages that start with a UTF-8 byte order mark. When a peer wrote a BOM before a message (as some Windows tools and shell redirections do), `ReadBuffer` passed it to `JSON.parse`, the transport reported a `SyntaxError` on `onerror`, 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/src/shared/stdio.ts b/src/shared/stdio.ts index 8f5478dfa8..364350d91e 100644 --- a/src/shared/stdio.ts +++ b/src/shared/stdio.ts @@ -32,7 +32,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); return deserializeMessage(line); } diff --git a/test/client/stdio.test.ts b/test/client/stdio.test.ts index 577e0c946f..21cd629174 100644 --- a/test/client/stdio.test.ts +++ b/test/client/stdio.test.ts @@ -116,3 +116,30 @@ test('should fire onerror and close when ReadBuffer overflows', async () => { expect(error.message).toMatch(/ReadBuffer exceeded maximum size/); 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)})]))`] + }); + + const errors: Error[] = []; + client.onerror = error => { + errors.push(error); + }; + const readMessages: JSONRPCMessage[] = []; + client.onmessage = message => { + readMessages.push(message); + }; + const closed = new Promise(resolve => { + client.onclose = () => resolve(); + }); + + await client.start(); + await closed; + + expect(errors).toEqual([]); + expect(readMessages).toEqual([expected]); +}); diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts index 87e666f7d0..9f7829c7f1 100644 --- a/test/shared/stdio.test.ts +++ b/test/shared/stdio.test.ts @@ -76,3 +76,31 @@ describe('buffer size limit', () => { expect(readBuffer.readMessage()).not.toBeNull(); }); }); + +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); + }); +});