Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

276 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
createMessageParser,
formatResponse,
processLine,
type JsonRpcResponse,
type RequestHandler,
} from "../src/mcp/transport.js";
function collector() {
const out: JsonRpcResponse[] = [];
const err: string[] = [];
return {
out,
err,
writeOut: (r: JsonRpcResponse) => out.push(r),
writeErr: (m: string) => err.push(m),
};
}
const okHandler: RequestHandler = async (method) => ({ method });
describe("processLine — request path", () => {
it("emits a response for a request with id", async () => {
const c = collector();
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0]).toEqual({
jsonrpc: "2.0",
id: 1,
result: { method: "initialize" },
});
});
it("emits an error response when the handler throws on a request", async () => {
const c = collector();
const throwingHandler: RequestHandler = async () => {
throw new Error("boom");
};
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }),
throwingHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe(7);
expect(c.out[0].error?.code).toBe(-32603);
expect(c.out[0].error?.message).toBe("boom");
});
});
describe("processLine — notification path (#129)", () => {
it("does NOT emit a response for a notification (no id field)", async () => {
const c = collector();
const handlerCalled = vi.fn(async () => ({ shouldNotEscape: true }));
await processLine(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).toHaveBeenCalledOnce();
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(0);
});
it("does NOT emit a response for a notification with id: null", async () => {
const c = collector();
await processLine(
JSON.stringify({
jsonrpc: "2.0",
id: null,
method: "notifications/cancelled",
}),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(0);
});
it("logs to stderr but does NOT emit a response when a notification handler throws", async () => {
const c = collector();
const throwingHandler: RequestHandler = async () => {
throw new Error("notification crash");
};
await processLine(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
throwingHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(1);
expect(c.err[0]).toContain("notification handler error");
expect(c.err[0]).toContain("notification crash");
});
});
describe("processLine — malformed input", () => {
it("emits a parse error with id: null for invalid JSON", async () => {
const c = collector();
await processLine("not-json", okHandler, c.writeOut, c.writeErr);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32700);
expect(c.out[0].error?.message).toBe("Parse error");
});
it("ignores empty / whitespace-only lines", async () => {
const c = collector();
await processLine("", okHandler, c.writeOut, c.writeErr);
await processLine(" \t ", okHandler, c.writeOut, c.writeErr);
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(0);
});
it("emits an Invalid Request error when a request has an id but no jsonrpc", async () => {
const c = collector();
await processLine(
JSON.stringify({ id: 1, method: "tools/list" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe(1);
expect(c.out[0].error?.code).toBe(-32600);
});
it("silently drops a malformed message that has no id (treated as notification)", async () => {
const c = collector();
await processLine(
JSON.stringify({ method: "broken" }),
okHandler,
c.writeOut,
c.writeErr,
);
// No jsonrpc field, no id — drop without responding.
expect(c.out).toHaveLength(0);
});
it("silently drops a malformed message with a non-primitive id (can't safely echo)", async () => {
const c = collector();
await processLine(
JSON.stringify({ id: { nested: true }, method: "broken" }),
okHandler,
c.writeOut,
c.writeErr,
);
// Malformed shape + non-primitive id — can't echo id back, drop silently.
expect(c.out).toHaveLength(0);
});
});
describe("processLine — id type validation (JSON-RPC §4)", () => {
it("rejects a request whose id is an object with -32600 and id: null", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({
jsonrpc: "2.0",
id: { bogus: true },
method: "tools/list",
}),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
expect(c.out[0].error?.message).toContain("id must be");
});
it("rejects a request whose id is an array", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: [1, 2], method: "tools/list" }),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
});
it("rejects a request whose id is a boolean", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: true, method: "tools/list" }),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
});
it("accepts a request with string id", async () => {
const c = collector();
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: "abc-123", method: "ping" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe("abc-123");
expect(c.out[0].result).toEqual({ method: "ping" });
});
});
describe("stdio framing", () => {
it("parses Content-Length framed MCP messages split across chunks", () => {
const messages: string[] = [];
const parser = createMessageParser((message) => messages.push(message));
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" });
const framed = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
parser.push(framed.slice(0, 12));
parser.push(framed.slice(12));
expect(messages).toEqual([body]);
expect(parser.isFramed()).toBe(true);
});
it("parses newline-delimited JSON for existing clients", () => {
const messages: string[] = [];
const parser = createMessageParser((message) => messages.push(message));
const first = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
const second = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" });
parser.push(`${first}\n${second}\n`);
expect(messages).toEqual([first, second]);
expect(parser.isFramed()).toBe(false);
});
it("formats responses with Content-Length framing when requested", () => {
const response: JsonRpcResponse = {
jsonrpc: "2.0",
id: 1,
result: { ok: true },
};
const formatted = formatResponse(response, true);
expect(Array.isArray(formatted)).toBe(true);
if (!Array.isArray(formatted)) throw new Error("expected framed response");
const header = formatted[0].toString("ascii");
const body = formatted[1].toString("utf8");
expect(header).toBe(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`);
expect(JSON.parse(body)).toEqual(response);
});
});