diff options
Diffstat (limited to 'packages/mcp/src/framing.ts')
| -rw-r--r-- | packages/mcp/src/framing.ts | 176 |
1 files changed, 134 insertions, 42 deletions
diff --git a/packages/mcp/src/framing.ts b/packages/mcp/src/framing.ts index 5f818e2..d30b871 100644 --- a/packages/mcp/src/framing.ts +++ b/packages/mcp/src/framing.ts @@ -1,32 +1,40 @@ /** - * Content-Length framing for MCP stdio transport. + * MCP stdio framing. * - * Each JSON-RPC message is framed as: - * Content-Length: <byte-length>\r\n\r\n<JSON bytes> + * The MCP spec (revision 2025-03-26 and later, including 2025-11-25) frames + * JSON-RPC messages over stdio as **newline-delimited JSON**: each message is + * a single JSON document followed by a `\n` (or `\r\n`). This replaced the + * older LSP-style `Content-Length: N\r\n\r\n<JSON>` framing that the protocol + * inherited originally. Modern servers (e.g. chrome-devtools-mcp) speak ONLY + * newline-delimited JSON — they emit `<JSON>\n` on stdout and do not respond to + * `Content-Length`-framed input at all. * - * Same framing as LSP — the MCP spec inherited this from the LSP base protocol. + * Outgoing messages are therefore encoded as newline-delimited JSON (the + * current spec default), so we can talk to modern servers. The decoder + * **auto-detects** the incoming framing per message — it accepts BOTH + * newline-delimited JSON and legacy `Content-Length` frames — so we still + * interoperate with servers that respond with the old framing. * * 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_PREFIX = "Content-Length:"; const HEADER_SEP = "\r\n\r\n"; const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i; -const SEP_BYTES = new TextEncoder().encode(HEADER_SEP); +const encoder = new TextEncoder(); +const SEP_BYTES = encoder.encode(HEADER_SEP); + +const CR = 0x0d; +const LF = 0x0a; /** - * Encode a JSON string into a single Content-Length-framed message. - * Returns the full frame (header + blank line + body) as bytes. + * Encode a JSON string as a single newline-delimited frame (the current MCP + * spec stdio framing): `<JSON>\n`. This is what we send to MCP servers. */ 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; + return encoder.encode(`${msg}\n`); } /** Find the first occurrence of `needle` in `haystack` at or after `from`. -1 if absent. */ @@ -46,9 +54,48 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n return -1; } +/** Find the first occurrence of a single byte at or after `from`. -1 if absent. */ +function indexOfByte(haystack: Uint8Array, needle: number, from: number): number { + for (let i = from; i < haystack.length; i++) { + if (haystack[i] === needle) return i; + } + return -1; +} + +/** Lowercase an ASCII byte (A-Z → a-z); leave everything else unchanged. */ +function toLowerByte(b: number): number { + return b >= 0x41 && b <= 0x5a ? b + 0x20 : b; +} + +function isLineTerminator(b: number | undefined): boolean { + return b === CR || b === LF; +} + +/** + * How does `buf` relate to the `Content-Length:` header prefix (case-insensitive)? + * - `HEADER_PREFIX.length` → `buf` starts with the full `Content-Length:` prefix. + * - a positive number `< HEADER_PREFIX.length` → `buf` is a (possibly partial) + * prefix of `Content-Length:` — ambiguous, the caller must wait for more bytes. + * - `-1` → `buf` definitively does NOT start with `Content-Length:` (not CL framing). + */ +function contentLengthPrefixLength(buf: Uint8Array): number { + const n = Math.min(buf.length, HEADER_PREFIX.length); + for (let i = 0; i < n; i++) { + const a = buf[i]; + if (a === undefined) return -1; // unreachable: i < n <= buf.length + if (toLowerByte(a) !== toLowerByte(HEADER_PREFIX.charCodeAt(i))) return -1; + } + return n; +} + /** * Feed raw bytes into the decoder. Returns all complete JSON messages that can * be extracted from the accumulated buffer. Buffers partial frames across calls. + * + * Auto-detects framing per message: a `Content-Length:`-prefixed buffer is parsed + * as a Content-Length frame (legacy/LSP-style); anything else is parsed as + * newline-delimited JSON (current MCP spec). Both framings may be mixed in a + * single stream. */ export class FrameDecoder { private buf: Uint8Array = new Uint8Array(0); @@ -56,45 +103,90 @@ export class FrameDecoder { 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; + if (chunk.length > 0) { + 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; - } + // 1. Skip leading CR/LF whitespace between frames. + let i = 0; + while (i < this.buf.length && isLineTerminator(this.buf[i])) i++; + if (i > 0) this.buf = this.buf.subarray(i); + if (this.buf.length === 0) break; - const length = Number.parseInt(match[1], 10); - if (length < 0) { - this.buf = this.buf.subarray(bodyStart); + // 2. Detect framing. + const prefix = contentLengthPrefixLength(this.buf); + if (prefix >= 0 && prefix < HEADER_PREFIX.length) { + // Buffer is a (possibly partial) prefix of "Content-Length:" — ambiguous; + // wait for more bytes before deciding this is (or isn't) a CL frame. + break; + } + if (prefix === HEADER_PREFIX.length) { + // Content-Length framing. + const result = this.tryParseContentLength(); + if (result === "incomplete") break; + if (result !== "skip") messages.push(result); continue; } - if (this.buf.length - bodyStart < length) { - // Body not fully received yet; wait for more bytes. - break; - } + // 3. Newline-delimited JSON: one message per line, terminated by `\n` + // (a trailing `\r` before the `\n` is tolerated). A raw newline byte + // can only ever appear as a message separator — JSON escapes newlines + // inside strings as the two characters `\n`, never a literal 0x0a — so + // splitting on `\n` bytes is safe for valid JSON. + const nl = indexOfByte(this.buf, LF, 0); + if (nl === -1) break; // incomplete line — wait for more bytes - // 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); + let end = nl; + if (end > 0 && this.buf[end - 1] === CR) end--; + const text = this.decoder.decode(this.buf.subarray(0, end)); + this.buf = this.buf.subarray(nl + 1); + if (text.length > 0) messages.push(text); } return messages; } + + /** + * Parse one Content-Length frame from the front of `this.buf`. Returns: + * - the decoded body string (possibly `""` for a zero-length body), + * - `"incomplete"` if the header or body hasn't fully arrived (caller waits), + * - `"skip"` if the header was consumed but carried no usable Content-Length + * (caller continues scanning without emitting a message). + */ + private tryParseContentLength(): string | "incomplete" | "skip" { + const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0); + if (sepIdx === -1) return "incomplete"; // header not fully received yet + + 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); + return "skip"; + } + + const length = Number.parseInt(match[1], 10); + if (length < 0) { + this.buf = this.buf.subarray(bodyStart); + return "skip"; + } + + if (this.buf.length - bodyStart < length) { + // Body not fully received yet; wait for more bytes. + return "incomplete"; + } + + // Decode exactly `length` body bytes (preserves multi-byte UTF-8). + const body = this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length)); + this.buf = this.buf.subarray(bodyStart + length); + return body; + } } |
