Skip to content

Commit c21bb06

Browse files
Ross StoryCopilot
andcommitted
Support framed stdio MCP transport
Accept Content-Length framed JSON-RPC messages in addition to the existing newline-delimited transport so Copilot CLI can initialize the standalone MCP server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 11a0200 commit c21bb06

2 files changed

Lines changed: 163 additions & 9 deletions

File tree

src/mcp/transport.ts

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { createInterface } from "node:readline";
2-
31
export interface JsonRpcRequest {
42
jsonrpc: "2.0";
53
id?: string | number;
@@ -19,6 +17,11 @@ export type RequestHandler = (
1917
params: Record<string, unknown>,
2018
) => Promise<unknown>;
2119

20+
export interface StdioMessageParser {
21+
push: (chunk: Buffer | string) => void;
22+
isFramed: () => boolean;
23+
}
24+
2225
// JSON-RPC 2.0 notifications are messages without an `id` field. The spec
2326
// (and the MCP transport contract) requires the server to NOT send a
2427
// response for notifications. Some clients tolerate spurious responses;
@@ -130,26 +133,131 @@ export async function processLine(
130133
}
131134
}
132135

136+
function findHeaderEnd(buffer: Buffer): { headerEnd: number; bodyStart: number } | null {
137+
const crlf = buffer.indexOf("\r\n\r\n");
138+
const lf = buffer.indexOf("\n\n");
139+
if (crlf === -1 && lf === -1) return null;
140+
if (crlf !== -1 && (lf === -1 || crlf <= lf)) {
141+
return { headerEnd: crlf, bodyStart: crlf + 4 };
142+
}
143+
return { headerEnd: lf, bodyStart: lf + 2 };
144+
}
145+
146+
function parseContentLength(header: string): number | null {
147+
for (const line of header.split(/\r?\n/)) {
148+
const match = line.match(/^content-length:\s*(\d+)\s*$/i);
149+
if (match) return Number(match[1]);
150+
}
151+
return null;
152+
}
153+
154+
export function formatResponse(
155+
response: JsonRpcResponse,
156+
framed: boolean,
157+
): string | Buffer[] {
158+
const body = JSON.stringify(response);
159+
if (!framed) return `${body}\n`;
160+
const bytes = Buffer.from(body, "utf8");
161+
return [Buffer.from(`Content-Length: ${bytes.length}\r\n\r\n`, "ascii"), bytes];
162+
}
163+
164+
export function createMessageParser(
165+
onMessage: (message: string) => void,
166+
writeErr: (msg: string) => void = (msg) => process.stderr.write(msg),
167+
): StdioMessageParser {
168+
let buffer = Buffer.alloc(0);
169+
let framed = false;
170+
171+
function processBuffer(): void {
172+
while (buffer.length > 0) {
173+
if (buffer[0] === 10 || buffer[0] === 13) {
174+
buffer = buffer.subarray(1);
175+
continue;
176+
}
177+
178+
const preview = buffer.toString("ascii", 0, Math.min(buffer.length, 32));
179+
if (/^content-length:/i.test(preview)) {
180+
const header = findHeaderEnd(buffer);
181+
if (!header) return;
182+
183+
const headerText = buffer.subarray(0, header.headerEnd).toString("ascii");
184+
const contentLength = parseContentLength(headerText);
185+
if (contentLength === null) {
186+
writeErr("[mcp-transport] missing Content-Length header\n");
187+
buffer = buffer.subarray(header.bodyStart);
188+
continue;
189+
}
190+
191+
const messageEnd = header.bodyStart + contentLength;
192+
if (buffer.length < messageEnd) return;
193+
194+
framed = true;
195+
const message = buffer.subarray(header.bodyStart, messageEnd).toString("utf8");
196+
buffer = buffer.subarray(messageEnd);
197+
onMessage(message);
198+
continue;
199+
}
200+
201+
const newline = buffer.indexOf(10);
202+
if (newline === -1) return;
203+
const line = buffer
204+
.subarray(0, newline)
205+
.toString("utf8")
206+
.replace(/\r$/, "");
207+
buffer = buffer.subarray(newline + 1);
208+
onMessage(line);
209+
}
210+
}
211+
212+
return {
213+
push(chunk) {
214+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
215+
buffer = Buffer.concat([buffer, bytes]);
216+
processBuffer();
217+
},
218+
isFramed() {
219+
return framed;
220+
},
221+
};
222+
}
223+
133224
export function createStdioTransport(handler: RequestHandler): {
134225
start: () => void;
135226
stop: () => void;
136227
} {
137-
let rl: ReturnType<typeof createInterface> | null = null;
228+
let parser: StdioMessageParser | null = null;
229+
let queue = Promise.resolve();
138230

139231
const writeResponse = (response: JsonRpcResponse) => {
140-
process.stdout.write(JSON.stringify(response) + "\n");
232+
const formatted = formatResponse(response, parser?.isFramed() ?? false);
233+
if (typeof formatted === "string") {
234+
process.stdout.write(formatted);
235+
return;
236+
}
237+
for (const chunk of formatted) {
238+
process.stdout.write(chunk);
239+
}
141240
};
142241

143-
const onLine = (line: string) => processLine(line, handler, writeResponse);
242+
const onData = (chunk: Buffer) => parser?.push(chunk);
144243

145244
return {
146245
start() {
147-
rl = createInterface({ input: process.stdin });
148-
rl.on("line", onLine);
246+
parser = createMessageParser((message) => {
247+
queue = queue.then(() => processLine(message, handler, writeResponse));
248+
void queue.catch((err) => {
249+
process.stderr.write(
250+
`[mcp-transport] request processing failed: ${
251+
err instanceof Error ? err.message : String(err)
252+
}\n`,
253+
);
254+
});
255+
});
256+
process.stdin.on("data", onData);
149257
},
150258
stop() {
151-
rl?.close();
152-
rl = null;
259+
process.stdin.off("data", onData);
260+
parser = null;
153261
},
154262
};
155263
}

test/mcp-transport.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect, vi } from "vitest";
22
import {
3+
createMessageParser,
4+
formatResponse,
35
processLine,
46
type JsonRpcResponse,
57
type RequestHandler,
@@ -227,3 +229,47 @@ describe("processLine — id type validation (JSON-RPC §4)", () => {
227229
expect(c.out[0].result).toEqual({ method: "ping" });
228230
});
229231
});
232+
233+
describe("stdio framing", () => {
234+
it("parses Content-Length framed MCP messages split across chunks", () => {
235+
const messages: string[] = [];
236+
const parser = createMessageParser((message) => messages.push(message));
237+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" });
238+
const framed = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
239+
240+
parser.push(framed.slice(0, 12));
241+
parser.push(framed.slice(12));
242+
243+
expect(messages).toEqual([body]);
244+
expect(parser.isFramed()).toBe(true);
245+
});
246+
247+
it("parses newline-delimited JSON for existing clients", () => {
248+
const messages: string[] = [];
249+
const parser = createMessageParser((message) => messages.push(message));
250+
const first = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
251+
const second = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" });
252+
253+
parser.push(`${first}\n${second}\n`);
254+
255+
expect(messages).toEqual([first, second]);
256+
expect(parser.isFramed()).toBe(false);
257+
});
258+
259+
it("formats responses with Content-Length framing when requested", () => {
260+
const response: JsonRpcResponse = {
261+
jsonrpc: "2.0",
262+
id: 1,
263+
result: { ok: true },
264+
};
265+
const formatted = formatResponse(response, true);
266+
267+
expect(Array.isArray(formatted)).toBe(true);
268+
if (!Array.isArray(formatted)) throw new Error("expected framed response");
269+
const header = formatted[0].toString("ascii");
270+
const body = formatted[1].toString("utf8");
271+
272+
expect(header).toBe(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`);
273+
expect(JSON.parse(body)).toEqual(response);
274+
});
275+
});

0 commit comments

Comments
 (0)