feat(packages): add mcp-stdio-transport.ts
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
export type StdioJsonRpcResponseMode = "line" | "framed";
|
||||
|
||||
export type StdioJsonRpcMessage =
|
||||
| {
|
||||
readonly kind: "request";
|
||||
readonly payload: unknown;
|
||||
readonly responseMode: StdioJsonRpcResponseMode;
|
||||
}
|
||||
| {
|
||||
readonly kind: "parse_error";
|
||||
readonly message: string;
|
||||
readonly responseMode: StdioJsonRpcResponseMode;
|
||||
};
|
||||
|
||||
type ReadResult =
|
||||
| { readonly kind: "incomplete" }
|
||||
| {
|
||||
readonly kind: "complete";
|
||||
readonly message?: StdioJsonRpcMessage;
|
||||
readonly remaining: Buffer<ArrayBufferLike>;
|
||||
};
|
||||
|
||||
const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
|
||||
|
||||
export async function* readStdioJsonRpcMessages(input: Readable): AsyncGenerator<StdioJsonRpcMessage> {
|
||||
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||
|
||||
for await (const chunk of input) {
|
||||
buffer = Buffer.concat([buffer, bufferFromChunk(chunk)]);
|
||||
while (true) {
|
||||
const result = readNextMessage(buffer);
|
||||
if (result.kind === "incomplete") break;
|
||||
buffer = result.remaining;
|
||||
if (result.message) yield result.message;
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = buffer.toString("utf8").trim();
|
||||
if (trailing.length > 0) {
|
||||
yield parseJsonPayload(trailing, "line");
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStdioJsonRpcResponse(output: Writable, response: unknown, responseMode: StdioJsonRpcResponseMode): void {
|
||||
const body = JSON.stringify(response);
|
||||
if (responseMode === "framed") {
|
||||
output.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
return;
|
||||
}
|
||||
output.write(`${body}\n`);
|
||||
}
|
||||
|
||||
function readNextMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
if (buffer.length === 0) return { kind: "incomplete" };
|
||||
return startsWithContentLength(buffer) ? readFramedMessage(buffer) : readLineMessage(buffer);
|
||||
}
|
||||
|
||||
function readLineMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
const newlineIndex = buffer.indexOf(0x0a);
|
||||
if (newlineIndex === -1) return { kind: "incomplete" };
|
||||
const line = buffer.subarray(0, newlineIndex).toString("utf8").replace(/\r$/, "");
|
||||
if (line.trim().length === 0) {
|
||||
return {
|
||||
kind: "complete",
|
||||
remaining: buffer.subarray(newlineIndex + 1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "complete",
|
||||
message: parseJsonPayload(line, "line"),
|
||||
remaining: buffer.subarray(newlineIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function readFramedMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
const separatorIndex = buffer.indexOf(HEADER_SEPARATOR);
|
||||
if (separatorIndex === -1) return { kind: "incomplete" };
|
||||
|
||||
const headers = buffer.subarray(0, separatorIndex).toString("ascii");
|
||||
const contentLength = parseContentLength(headers);
|
||||
const bodyStart = separatorIndex + HEADER_SEPARATOR.length;
|
||||
if (contentLength === undefined) {
|
||||
return {
|
||||
kind: "complete",
|
||||
message: {
|
||||
kind: "parse_error",
|
||||
message: "Missing or invalid Content-Length header",
|
||||
responseMode: "framed",
|
||||
},
|
||||
remaining: buffer.subarray(bodyStart),
|
||||
};
|
||||
}
|
||||
|
||||
const bodyEnd = bodyStart + contentLength;
|
||||
if (buffer.length < bodyEnd) return { kind: "incomplete" };
|
||||
const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
|
||||
return {
|
||||
kind: "complete",
|
||||
message: parseJsonPayload(body, "framed"),
|
||||
remaining: buffer.subarray(bodyEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function startsWithContentLength(buffer: Buffer<ArrayBufferLike>): boolean {
|
||||
const prefix = buffer.subarray(0, "content-length:".length).toString("ascii").toLowerCase();
|
||||
return prefix === "content-length:";
|
||||
}
|
||||
|
||||
function parseContentLength(headers: string): number | undefined {
|
||||
for (const line of headers.split("\r\n")) {
|
||||
const match = /^content-length:\s*(\d+)$/i.exec(line);
|
||||
if (match === null) continue;
|
||||
const value = match[1];
|
||||
if (value === undefined) return undefined;
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonPayload(payload: string, responseMode: StdioJsonRpcResponseMode): StdioJsonRpcMessage {
|
||||
try {
|
||||
return { kind: "request", payload: JSON.parse(payload), responseMode };
|
||||
} catch (error) {
|
||||
return { kind: "parse_error", message: messageFromError(error), responseMode };
|
||||
}
|
||||
}
|
||||
|
||||
function bufferFromChunk(chunk: unknown): Buffer<ArrayBufferLike> {
|
||||
if (Buffer.isBuffer(chunk)) return chunk;
|
||||
if (typeof chunk === "string") return Buffer.from(chunk);
|
||||
throw new TypeError(`Unsupported stdio chunk type: ${typeof chunk}`);
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in New Issue
Block a user