ReadBuffer.readMessage() currently advances the buffer with:
this._buffer = this._buffer.subarray(index + 1);
When the newline is the final byte, subarray() returns an empty Buffer view. The view still references the backing allocation of the accumulated input buffer.
That allocation can contain more than the final message, for example when one input chunk contains multiple messages or uses a pooled buffer. It stays referenced until another chunk is appended, clear() is called, or the transport closes.
There is also a cost on the next append. An empty Buffer is truthy, so this code still takes the concat path:
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; |
The next append therefore copies the incoming chunk even though no buffered bytes remain. During that copy, the retained backing allocation, the incoming chunk, and the new concat destination are all live.
This is not an unbounded leak, but it unnecessarily increases idle external memory, peak memory, and copying for long-lived stdio processes. The extra peak memory matters when large messages are allowed or the process runs under a memory limit.
I confirmed this behavior in:
- the currently published @modelcontextprotocol/sdk v1.29.0
- the current v1.x branch:
|
readMessage(): JSONRPCMessage | null { |
|
if (!this._buffer) { |
|
return null; |
|
} |
|
|
|
const index = this._buffer.indexOf('\n'); |
|
if (index === -1) { |
|
return null; |
|
} |
|
|
|
const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); |
|
this._buffer = this._buffer.subarray(index + 1); |
|
return deserializeMessage(line); |
|
} |
- the current main branch used by the v2 packages:
|
readMessage(): JSONRPCMessage | null { |
|
while (this._buffer) { |
|
const index = this._buffer.indexOf('\n'); |
|
if (index === -1) { |
|
return null; |
|
} |
|
|
|
const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); |
|
this._buffer = this._buffer.subarray(index + 1); |
|
|
|
try { |
|
return deserializeMessage(line); |
|
} catch (error) { |
|
// Skip non-JSON lines (e.g., debug output from hot-reload tools like |
|
// tsx or nodemon that write to stdout). Schema validation errors still |
|
// throw so malformed-but-valid-JSON messages surface via onerror. |
|
if (error instanceof SyntaxError) { |
|
continue; |
|
} |
|
throw error; |
|
} |
|
} |
|
return null; |
|
} |
A small fix would clear the buffer when no bytes remain:
const remainder = this._buffer.subarray(index + 1);
this._buffer = remainder.length === 0 ? undefined : remainder;
This does not copy non-empty remainders or change the public API. It releases the consumed backing allocation and lets the next append assign its chunk directly instead of using Buffer.concat().
ReadBuffer.readMessage()currently advances the buffer with:When the newline is the final byte,
subarray()returns an emptyBufferview. The view still references the backing allocation of the accumulated input buffer.That allocation can contain more than the final message, for example when one input chunk contains multiple messages or uses a pooled buffer. It stays referenced until another chunk is appended,
clear()is called, or the transport closes.There is also a cost on the next append. An empty Buffer is truthy, so this code still takes the concat path:
typescript-sdk/src/shared/stdio.ts
Line 22 in 69749aa
The next append therefore copies the incoming chunk even though no buffered bytes remain. During that copy, the retained backing allocation, the incoming chunk, and the new concat destination are all live.
This is not an unbounded leak, but it unnecessarily increases idle external memory, peak memory, and copying for long-lived stdio processes. The extra peak memory matters when large messages are allowed or the process runs under a memory limit.
I confirmed this behavior in:
typescript-sdk/src/shared/stdio.ts
Lines 25 to 38 in 69749aa
typescript-sdk/packages/core-internal/src/shared/stdio.ts
Lines 26 to 49 in 1e1392e
A small fix would clear the buffer when no bytes remain:
This does not copy non-empty remainders or change the public API. It releases the consumed backing allocation and lets the next append assign its chunk directly instead of using
Buffer.concat().