Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/stdio-byte-order-mark.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
27 changes: 27 additions & 0 deletions test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(resolve => {
client.onclose = () => resolve();
});

await client.start();
await closed;

expect(errors).toEqual([]);
expect(readMessages).toEqual([expected]);
});
28 changes: 28 additions & 0 deletions test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading