Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/stdio-byte-order-mark.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions packages/client/test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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
Expand Down
7 changes: 6 additions & 1 deletion packages/core-internal/src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions packages/core-internal/test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading