blob: a6e8b99bc2aa861528d0e6de2ed32bb9ee3928ea (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
|
/**
* Content-Length framing for MCP stdio transport.
*
* Each JSON-RPC message is framed as:
* Content-Length: <byte-length>\r\n\r\n<JSON bytes>
*
* Same framing as LSP — the MCP spec inherited this from the LSP base protocol.
*
* PURE: no I/O. Operates on bytes (Uint8Array) so multi-byte UTF-8 content is
* handled correctly — `Content-Length` is a *byte* count, not a character count.
*/
const HEADER_SEP = "\r\n\r\n";
const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i;
const SEP_BYTES = new TextEncoder().encode(HEADER_SEP);
/**
* Encode a JSON string into a single Content-Length-framed message.
* Returns the full frame (header + blank line + body) as bytes.
*/
export function encode(msg: string): Uint8Array {
const body = new TextEncoder().encode(msg);
const header = `Content-Length: ${body.length}\r\n\r\n`;
const frame = new TextEncoder().encode(header);
const result = new Uint8Array(frame.length + body.length);
result.set(frame);
result.set(body, frame.length);
return result;
}
/** Find the first occurrence of `needle` in `haystack` at or after `from`. -1 if absent. */
function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number {
if (needle.length === 0) return from;
const max = haystack.length - needle.length;
for (let i = from; i <= max; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) {
match = false;
break;
}
}
if (match) return i;
}
return -1;
}
/**
* Feed raw bytes into the decoder. Returns all complete JSON messages that can
* be extracted from the accumulated buffer. Buffers partial frames across calls.
*/
export class FrameDecoder {
private buf: Uint8Array = new Uint8Array(0);
private readonly decoder = new TextDecoder();
decode(chunk: Uint8Array): string[] {
// Append the incoming chunk to the internal byte buffer.
const next = new Uint8Array(this.buf.length + chunk.length);
next.set(this.buf);
next.set(chunk, this.buf.length);
this.buf = next;
const messages: string[] = [];
while (true) {
const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0);
if (sepIdx === -1) break;
// The header block is everything before the separator; parse
// Content-Length from it (ASCII, so decoding the slice is safe).
const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx));
const match = CONTENT_LENGTH_RE.exec(headerText);
const bodyStart = sepIdx + SEP_BYTES.length;
if (!match?.[1]) {
// No usable Content-Length — drop this header and continue scanning.
this.buf = this.buf.subarray(bodyStart);
continue;
}
const length = Number.parseInt(match[1], 10);
if (length < 0) {
this.buf = this.buf.subarray(bodyStart);
continue;
}
if (this.buf.length - bodyStart < length) {
// Body not fully received yet; wait for more bytes.
break;
}
// Decode exactly `length` body bytes (preserves multi-byte UTF-8).
messages.push(this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length)));
this.buf = this.buf.subarray(bodyStart + length);
}
return messages;
}
}
|