Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
/**
* MCPClient edge-case coverage — exercises client-side branches not naturally
* hit by happy-path tests: malformed initialize result, completion-provider
* wire path, no-provider fallback, list-changed catch handlers, no-arg
* callTool/getPrompt, injected clock+idGenerator, disconnect-with-reason.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCPClient } from "../client/mcp-client/mcp-client.js";
import { MCP_CODES } from "../error-codes/error-codes.js";
import { JsonRpcDispatcher } from "../jsonrpc/dispatcher/dispatcher.js";
import { MCPServer } from "../server/mcp-server/mcp-server.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
describe("client edge cases", () => {
it("client without roots provider returns cached roots", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
client.registerRoot({ uri: "file:///pre" });
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const roots = await server.listClientRoots();
expect(Array.isArray(roots)).toBe(true);
await client.disconnect();
await server.stop();
});
it("client list-changed notifications trigger refresh", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
server.registerTool({
name: "later",
inputSchema: {},
async handler() {
return { content: [] };
},
});
server.registerResource({
uri: "mem://later",
name: "later",
async handler() {
return { uri: "mem://later" };
},
});
server.registerPrompt({
name: "later",
async handler() {
return [];
},
});
await new Promise((r) => setTimeout(r, 50));
await client.disconnect();
await server.stop();
});
it("client malformed initialize result raises PROTOCOL_INVALID", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const fakeServer = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "fake",
});
fakeServer.registerHandler("initialize", async () => ({
protocolVersion: "2024-11-05",
}));
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
await expect(client.connect(b)).rejects.toMatchObject({
code: MCP_CODES.PROTOCOL_INVALID,
});
await fakeServer.close();
});
it("client completion provider serves wire-side completion/complete", {
timeout: 5000,
}, async () => {
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
client.setCompletionProvider(async () => ({
completion: { values: ["x", "y"], hasMore: false },
}));
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const srvDispatcher = (
server as unknown as { dispatcher: JsonRpcDispatcher }
).dispatcher;
const result = (await srvDispatcher.request("completion/complete", {
ref: { type: "ref/prompt", name: "p" },
argument: { name: "a", value: "b" },
})) as { completion: { values: string[] } };
expect(result.completion.values).toEqual(["x", "y"]);
await client.disconnect();
await server.stop();
});
it("client completion fallback returns empty values without provider", {
timeout: 5000,
}, async () => {
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const srvDispatcher = (
server as unknown as { dispatcher: JsonRpcDispatcher }
).dispatcher;
const result = (await srvDispatcher.request("completion/complete", {
ref: { type: "ref/prompt", name: "p" },
argument: { name: "a", value: "b" },
})) as { completion: { values: string[]; hasMore: boolean } };
expect(result.completion.values).toEqual([]);
expect(result.completion.hasMore).toBe(false);
await client.disconnect();
await server.stop();
});
it("client constructor wires injected clock and idGenerator", {
timeout: 5000,
}, async () => {
let n = 0;
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
clock: () => 12345,
idGenerator: () => {
n += 1;
return `id-${n}`;
},
});
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
expect(n).toBeGreaterThan(0);
await client.disconnect();
await server.stop();
});
it("client disconnect with reason emits closed-with-reason event", {
timeout: 5000,
}, async () => {
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const observed: Array<{ reason?: string }> = [];
client.events.on("connection:closed", (e) =>
observed.push(e as { reason?: string })
);
await client.disconnect("graceful");
expect(observed[0]?.reason).toBe("graceful");
await client.disconnect();
await server.stop();
});
it("client getPrompt without args uses default empty object", {
timeout: 5000,
}, async () => {
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerPrompt({
name: "p",
async handler() {
return [
{
role: "user" as const,
content: { type: "text" as const, text: "ok" },
},
];
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const msgs = await client.getPrompt("p");
expect(msgs.length).toBe(1);
await client.disconnect();
await server.stop();
});
it("client request honours an explicit zero timeoutMs option", {
timeout: 5000,
}, async () => {
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
defaultTimeoutMs: 0,
});
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "echo",
inputSchema: {},
async handler() {
return { content: [{ type: "text", text: "ok" }] };
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const r = await client.callTool("echo", {}, { timeoutMs: 0 });
expect(r.content.length).toBe(1);
await client.disconnect();
await server.stop();
});
it("client list-changed catch handlers swallow refresh failures", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const srvDispatcher = (
server as unknown as { dispatcher: JsonRpcDispatcher }
).dispatcher;
srvDispatcher.unregisterHandler("tools/list");
srvDispatcher.unregisterHandler("resources/list");
srvDispatcher.unregisterHandler("prompts/list");
await srvDispatcher.notify("notifications/tools/list_changed");
await srvDispatcher.notify("notifications/resources/list_changed");
await srvDispatcher.notify("notifications/prompts/list_changed");
await new Promise((r) => setTimeout(r, 50));
await client.disconnect();
await server.stop();
});
it("client ping handler responds when peer requests it", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const srvDispatcher = (
server as unknown as { dispatcher: JsonRpcDispatcher }
).dispatcher;
const pong = await srvDispatcher.request("ping", {});
expect(pong).toEqual({});
await client.disconnect();
await server.stop();
});
it("client subscribed resource notifications fire onMessage path", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://x",
name: "x",
async handler() {
return { uri: "mem://x", text: "" };
},
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
await client.subscribeResource("mem://x");
const events: unknown[] = [];
client.events.on("notification:emitted", (e) => {
if (e.method === "notifications/resources/updated") {
events.push(e.params);
}
});
server.markResourceUpdated("mem://x");
await new Promise((r) => setTimeout(r, 20));
expect(events.length).toBe(1);
await client.unsubscribeResource("mem://x");
await client.disconnect();
await server.stop();
});
it("callTool with no args uses the default empty object", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "noargs",
inputSchema: {},
async handler() {
return { content: [{ type: "text", text: "ok" }] };
},
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const r = await client.callTool("noargs");
expect(r.content[0]).toMatchObject({ text: "ok" });
await client.disconnect();
await server.stop();
});
});

View File

@@ -0,0 +1,337 @@
/**
* Dispatcher edge-case coverage — exercises JsonRpcDispatcher internals not
* naturally hit by the happy-path test files: transport-error wrapping,
* non-Error throws, wire-code mapping, schema-fallback, double-close,
* cancellation parse failures, and notification listener reuse.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCP_CODES } from "../error-codes/error-codes.js";
import { JsonRpcDispatcher } from "../jsonrpc/dispatcher/dispatcher.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
describe("dispatcher edge cases", () => {
it("dispatcher notify after close rejects", { timeout: 5000 }, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
await right.close();
await expect(right.notify("anything")).rejects.toMatchObject({
code: MCP_CODES.TRANSPORT_CLOSED,
});
await left.close();
});
it("transport send throwing maps to MCPTransportError", {
timeout: 5000,
}, async () => {
const { BaseTransport } = await import(
"../transport/transport/transport.js"
);
class ThrowingTransport extends BaseTransport {
protected override async sendInternal(): Promise<void> {
throw new Error("kaboom");
}
}
const t = new ThrowingTransport();
const d = new JsonRpcDispatcher({
side: "client",
transport: t,
dispatcherId: "T",
});
await expect(d.notify("hi")).rejects.toMatchObject({
code: MCP_CODES.TRANSPORT_ERROR,
});
await d.close();
});
it("dispatcher handleResponse drops unknown ids silently", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
await a.send({ jsonrpc: "2.0", id: "ghost", result: {} });
await a.send({ jsonrpc: "2.0", id: 1 } as never);
await new Promise((r) => setTimeout(r, 10));
await left.close();
await right.close();
});
it("dispatcher response with error envelope rejects", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("custom", async () => {
const err = new (
await import("../error-codes/error-codes.js")
).MCPError(MCP_CODES.UNAUTHORIZED, "no!");
throw err;
});
await expect(right.request("custom", {})).rejects.toMatchObject({
code: MCP_CODES.UNAUTHORIZED,
});
await left.close();
await right.close();
});
it("unregisterHandler removes a registered method", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("temp", async () => ({ ok: true }));
await right.request("temp", {});
left.unregisterHandler("temp");
await expect(right.request("temp", {})).rejects.toMatchObject({
code: MCP_CODES.METHOD_NOT_FOUND,
});
await left.close();
await right.close();
});
it("onNotification reuses an existing listener set", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
const observedA: unknown[] = [];
const observedB: unknown[] = [];
const offA = left.onNotification("ev", (p) => observedA.push(p));
const offB = left.onNotification("ev", (p) => observedB.push(p));
await right.notify("ev", { n: 1 });
await new Promise((r) => setTimeout(r, 10));
expect(observedA).toEqual([{ n: 1 }]);
expect(observedB).toEqual([{ n: 1 }]);
offA();
offB();
await left.close();
await right.close();
});
it("request send rejection cleans up pending entry", {
timeout: 5000,
}, async () => {
const { BaseTransport } = await import(
"../transport/transport/transport.js"
);
class FlakyTransport extends BaseTransport {
public allowedSends = 0;
protected override async sendInternal(): Promise<void> {
if (this.allowedSends > 0) {
this.allowedSends -= 1;
return;
}
throw new Error("send-failed");
}
}
const t = new FlakyTransport();
const d = new JsonRpcDispatcher({
side: "client",
transport: t,
dispatcherId: "Q",
});
await expect(d.request("ping", {})).rejects.toMatchObject({
code: MCP_CODES.TRANSPORT_ERROR,
});
await d.close();
});
it("request send non-Error rejection still cleans up", {
timeout: 5000,
}, async () => {
const { BaseTransport } = await import(
"../transport/transport/transport.js"
);
class NonErrTransport extends BaseTransport {
protected override async sendInternal(): Promise<void> {
const v: unknown = "string-thrown";
throw v;
}
}
const t = new NonErrTransport();
const d = new JsonRpcDispatcher({
side: "client",
transport: t,
dispatcherId: "Q2",
});
await expect(d.request("ping", {})).rejects.toBeDefined();
await d.close();
});
it("incoming response with unknown numeric id is dropped silently", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
await a.send({ jsonrpc: "2.0", id: 42, result: { ignored: true } });
await new Promise((r) => setTimeout(r, 10));
await left.close();
await right.close();
});
it("handler abort race rejects without responding", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("racy", async (_p, ctx) => {
await new Promise((resolve) => {
ctx.signal.addEventListener("abort", () => resolve(null));
});
throw new Error("post-abort throw");
});
const ac = new AbortController();
const promise = right.request("racy", {}, { signal: ac.signal });
await Promise.resolve();
ac.abort();
await expect(promise).rejects.toMatchObject({
code: MCP_CODES.REQUEST_CANCELLED,
});
await new Promise((r) => setTimeout(r, 20));
await left.close();
await right.close();
});
it("error response codes round-trip through mapMcpCodeToJsonRpc", {
timeout: 5000,
}, async () => {
const { MCPError } = await import("../error-codes/error-codes.js");
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("u", async () => {
throw new MCPError(MCP_CODES.UNAUTHORIZED, "u");
});
left.registerHandler("c", async () => {
throw new MCPError(MCP_CODES.REQUEST_CANCELLED, "c");
});
left.registerHandler("p", async () => {
throw new MCPError(MCP_CODES.PROTOCOL_INVALID, "p");
});
await expect(right.request("u", {})).rejects.toMatchObject({
code: MCP_CODES.UNAUTHORIZED,
});
await expect(right.request("c", {})).rejects.toMatchObject({
code: MCP_CODES.REQUEST_CANCELLED,
});
await expect(right.request("p", {})).rejects.toMatchObject({
code: MCP_CODES.PROTOCOL_INVALID,
});
await left.close();
await right.close();
});
it("raw JSON-RPC wire codes map through mapJsonRpcCode", {
timeout: 5000,
}, async () => {
const codes: Array<{ wire: number; expected: string }> = [
{ wire: -32601, expected: MCP_CODES.METHOD_NOT_FOUND },
{ wire: -32602, expected: MCP_CODES.INVALID_PARAMS },
{ wire: -32001, expected: MCP_CODES.UNAUTHORIZED },
{ wire: -32002, expected: MCP_CODES.REQUEST_CANCELLED },
{ wire: -32700, expected: MCP_CODES.PROTOCOL_INVALID },
{ wire: -32600, expected: MCP_CODES.PROTOCOL_INVALID },
{ wire: -99999, expected: MCP_CODES.INTERNAL_ERROR },
];
for (const c of codes) {
const [a, b] = InMemoryTransport.pair();
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
const seen = new Promise<string | number>((resolve) => {
a.onMessage((msg) => {
if (
msg !== null &&
typeof msg === "object" &&
"method" in msg &&
"id" in msg
) {
resolve((msg as { id: string | number }).id);
}
});
});
const reqP = right.request("probe", {}).catch((e: unknown) => e);
const id = await seen;
await a.send({
jsonrpc: "2.0",
id,
error: { code: c.wire, message: "wire" },
});
const err = (await reqP) as { code: string };
expect(err.code).toBe(c.expected);
await right.close();
}
});
});

View File

@@ -0,0 +1,254 @@
/**
* Dispatcher internal-state coverage — id stringification, double-close
* idempotence, schema-fallback for undefined params, sendProgress
* total-omission, unparseable wire messages, cancellation parse failures,
* setProgressToken edge cases, non-Error handler throws.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCP_CODES } from "../error-codes/error-codes.js";
import { JsonRpcDispatcher } from "../jsonrpc/dispatcher/dispatcher.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
describe("dispatcher internals edge cases", () => {
it("dispatcher stringifies numeric ids distinctly from strings", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("nop", async () => ({}));
await a.send({ jsonrpc: "2.0", id: 9999, result: {} });
await new Promise((r) => setTimeout(r, 10));
await left.close();
await right.close();
});
it("dispatcher close is idempotent over double-close", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
await left.close();
await left.close();
await right.close();
});
it("dispatcher schema-validated request without params uses default {}", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
paramSchemas: {
nothing: z.default.object({}).optional(),
},
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("nothing", async () => ({ ok: true }));
const result = await right.request("nothing", undefined);
expect(result).toEqual({ ok: true });
await left.close();
await right.close();
});
it("dispatcher sendProgress omits total when undefined", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("p", async (_params, ctx) => {
ctx.sendProgress("tok", 1);
return { ok: true };
});
const observed: unknown[] = [];
right.onNotification("notifications/progress", (p) => observed.push(p));
await right.request("p", {});
await new Promise((r) => setTimeout(r, 10));
expect(observed[0]).toEqual({ progressToken: "tok", progress: 1 });
await left.close();
await right.close();
});
it("dispatcher silently drops unparseable wire messages", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
await b.send({} as never);
await new Promise((r) => setTimeout(r, 10));
await left.close();
await b.close();
});
it("dispatcher schema-validated request handles undefined params via fallback", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
paramSchemas: {
opt: z.default.object({}).passthrough().optional(),
},
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("opt", async () => ({ ok: true }));
const seen = new Promise<string | number>((resolve) => {
a.onMessage((msg) => {
if (
msg !== null &&
typeof msg === "object" &&
"method" in msg &&
"id" in msg
) {
resolve((msg as { id: string | number }).id);
}
});
});
const reqP = right.request("opt", undefined);
await seen;
await reqP;
await left.close();
await right.close();
});
it("CancelledNotificationParamsSchema parse failure is skipped silently", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
await b.send({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: { not: "valid" },
});
await b.send({
jsonrpc: "2.0",
method: "notifications/progress",
params: { also: "invalid" },
});
await new Promise((r) => setTimeout(r, 10));
await left.close();
await b.close();
});
it("setProgressToken with already-tagged pending entry skips it", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("slow", async (_p, ctx) => {
await new Promise((r) => setTimeout(r, 30));
ctx.sendProgress("first", 1);
return { ok: true };
});
const p1 = right.request("slow", undefined, {
onProgress: () => {},
});
right.setProgressToken("slow", "first");
right.setProgressToken("slow", "second");
await p1;
await left.close();
await right.close();
});
it("setProgressToken is no-op when method has no matching pending entry", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
right.setProgressToken("never-sent", "tok");
await left.close();
await right.close();
});
it("handler throwing a non-Error stringifies via String(err)", {
timeout: 5000,
}, async () => {
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("yeet", async () => {
const v: unknown = { not: "an Error" };
throw v;
});
await expect(right.request("yeet", {})).rejects.toMatchObject({
code: MCP_CODES.INTERNAL_ERROR,
});
await left.close();
await right.close();
});
});

View File

@@ -0,0 +1,252 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCPClient } from "../client/mcp-client/mcp-client.js";
import { MCPServer } from "../server/mcp-server/mcp-server.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) {
out.push(...walk(p));
} else {
out.push(p);
}
}
return out;
}
describe("e2e", () => {
it("walks the full MCP method surface end-to-end", {
timeout: 10_000,
}, async () => {
const server = new MCPServer({
info: { name: "demo-server", version: "0.1.0" },
description: "e2e demo",
});
server.registerTool({
name: "echo",
description: "echo",
inputSchema: { type: "object" },
async handler(args) {
return {
content: [
{
type: "text",
text: JSON.stringify(args),
},
],
};
},
});
server.registerResource({
uri: "mem://greeting",
name: "greeting",
mimeType: "text/plain",
async handler() {
return { uri: "mem://greeting", text: "hello" };
},
});
server.registerResourceTemplate({
uriTemplate: "mem://{id}",
name: "any",
});
server.registerPrompt({
name: "say-hello",
async handler(args) {
return [
{
role: "user",
content: {
type: "text",
text: `hi ${args.who ?? "stranger"}`,
},
},
];
},
});
server.setCompletionProvider(async () => ({
completion: { values: ["alpha", "beta"] },
}));
const client = new MCPClient({
info: { name: "demo-client", version: "0.1.0" },
capabilities: { sampling: {}, roots: { listChanged: true } },
});
client.setSamplingProvider(async () => ({
role: "assistant",
content: { type: "text", text: "sampled" },
model: "demo",
}));
client.setRootsProvider(async () => [
{ uri: "file:///workspace", name: "ws" },
]);
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
// initialize already completed inside connect().
expect(client.initialized).toBe(true);
// tools/list + tools/call.
const tools = client.tools();
expect(tools.map((t) => t.name)).toContain("echo");
const callResult = await client.callTool("echo", { x: 1 });
expect(callResult.isError ?? false).toBe(false);
// resources/list + resources/templates/list + resources/read.
expect(client.resources()[0]?.uri).toBe("mem://greeting");
expect(client.resourceTemplates()[0]?.uriTemplate).toBe("mem://{id}");
const contents = await client.readResource("mem://greeting");
expect(contents[0]?.text).toBe("hello");
// resources/subscribe + markResourceUpdated.
await client.subscribeResource("mem://greeting");
server.markResourceUpdated("mem://greeting");
await new Promise((r) => setTimeout(r, 20));
await client.unsubscribeResource("mem://greeting");
// prompts/list + prompts/get.
const messages = await client.getPrompt("say-hello", { who: "x" });
expect(messages.length).toBe(1);
// completion/complete.
const completion = await client.complete({
ref: { type: "ref/prompt", name: "say-hello" },
argument: { name: "who", value: "x" },
});
expect(completion.completion.values).toEqual(["alpha", "beta"]);
// sampling/createMessage (server-initiated).
const sample = await server.createSamplingMessage({
messages: [
{
role: "user",
content: { type: "text", text: "hi" },
},
],
maxTokens: 10,
});
expect(sample.role).toBe("assistant");
// roots/list (server-initiated).
const roots = await server.listClientRoots();
expect(roots[0]?.uri).toBe("file:///workspace");
// logging/setLevel + ping.
await client.setLogLevel("warning");
expect(server.getInfo().logLevel).toBe("warning");
await client.ping();
await client.disconnect();
await server.stop();
});
it("progress and cancellation propagate over the wire", {
timeout: 10_000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "slow",
inputSchema: {},
async handler(_args, ctx) {
for (let i = 1; i <= 3 && !ctx.signal.aborted; i++) {
ctx.progress(i, 3);
await new Promise((r) => setTimeout(r, 5));
}
return { content: [{ type: "text", text: "done" }] };
},
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const progress: number[] = [];
const result = await client.callTool(
"slow",
{},
{ onProgress: (n) => progress.push(n) }
);
expect(progress.length).toBeGreaterThan(0);
expect(result.content[0]).toMatchObject({ type: "text" });
await client.disconnect();
await server.stop();
});
it("resource subscriptions deliver updates", {
timeout: 10_000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://x",
name: "x",
async handler() {
return { uri: "mem://x", text: "v" };
},
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const updates: unknown[] = [];
client.events.on("notification:emitted", (e) => {
if (e.method === "notifications/resources/updated") {
updates.push(e.params);
}
});
await client.subscribeResource("mem://x");
server.markResourceUpdated("mem://x");
await new Promise((r) => setTimeout(r, 20));
expect(updates.length).toBe(1);
await client.disconnect();
await server.stop();
});
it("source tree contains no Node built-in imports", {
timeout: 5_000,
}, () => {
const srcRoot = join(__dirname, "..");
const files = walk(srcRoot).filter(
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
);
const offenders: string[] = [];
for (const f of files) {
if (f.includes("/e2e/")) continue;
const content = readFileSync(f, "utf8");
if (
/from\s+["']node:/.test(content) ||
/require\(["']node:/.test(content)
) {
offenders.push(f);
}
}
expect(offenders).toEqual([]);
});
it("source tree contains no DOM globals", { timeout: 5_000 }, () => {
const srcRoot = join(__dirname, "..");
const files = walk(srcRoot).filter(
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
);
const offenders: string[] = [];
for (const f of files) {
const content = readFileSync(f, "utf8");
if (/\bdocument\.|\bwindow\.|\bnavigator\./.test(content)) {
offenders.push(f);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,444 @@
/**
* MCPServer edge-case coverage — exercises server-side branches not naturally
* hit by happy-path tests: provider fall-backs, list-helpers with optional
* fields, normaliseToolResult array path, Zod root-level error formatting,
* authorize-true branch, signal forwarding to direct invocation API.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCPClient } from "../client/mcp-client/mcp-client.js";
import { MCP_CODES } from "../error-codes/error-codes.js";
import { JsonRpcDispatcher } from "../jsonrpc/dispatcher/dispatcher.js";
import { MCPServer } from "../server/mcp-server/mcp-server.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
describe("server edge cases", () => {
it("server without sampling/completion provider falls back", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const completion = await peer.request("completion/complete", {
ref: { type: "ref/prompt", name: "x" },
argument: { name: "a", value: "b" },
});
expect(
(completion as { completion: { values: string[] } }).completion
.values
).toEqual([]);
const pong = await peer.request("ping", {});
expect(pong).toEqual({});
const list = await peer.request("resources/list", {});
expect((list as { resources: unknown[] }).resources).toEqual([]);
const tplList = await peer.request("resources/templates/list", {});
expect(
(tplList as { resourceTemplates: unknown[] }).resourceTemplates
).toEqual([]);
await expect(
peer.request("tools/call", { name: "nope" })
).rejects.toMatchObject({ code: MCP_CODES.INTERNAL_ERROR });
await expect(
peer.request("resources/read", { uri: "mem://nope" })
).rejects.toMatchObject({ code: MCP_CODES.INTERNAL_ERROR });
await expect(
peer.request("prompts/get", { name: "nope" })
).rejects.toMatchObject({ code: MCP_CODES.INTERNAL_ERROR });
await peer.request("resources/subscribe", { uri: "mem://x" });
await peer.request("resources/unsubscribe", { uri: "mem://x" });
await peer.close();
await server.stop();
});
it("tool with paramsSchema validates over the dispatcher", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "validated",
inputSchema: {},
paramsSchema: z.default.object({ n: z.default.number() }),
async handler(args) {
return {
content: [
{
type: "text",
text: String((args as { n: number }).n),
},
],
};
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
await expect(
peer.request("tools/call", {
name: "validated",
arguments: { n: "not-a-number" },
})
).rejects.toMatchObject({ code: MCP_CODES.INVALID_PARAMS });
const ok = await peer.request("tools/call", {
name: "validated",
arguments: { n: 7 },
});
expect(
(ok as { content: Array<{ text: string }> }).content[0]?.text
).toBe("7");
await peer.close();
await server.stop();
});
it("readResource and getPromptMessages accept signal", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://x",
name: "x",
async handler({ signal }) {
return {
uri: "mem://x",
text: signal.aborted ? "aborted" : "ok",
};
},
});
server.registerPrompt({
name: "p",
async handler() {
return [
{
role: "user",
content: { type: "text", text: "hi" },
},
];
},
});
const ac = new AbortController();
const r = await server.readResource("mem://x", { signal: ac.signal });
expect(r[0]?.text).toBe("ok");
const m = await server.getPromptMessages(
"p",
{},
{ signal: ac.signal }
);
expect(m.length).toBe(1);
server.registerTool({
name: "fast",
inputSchema: {},
async handler(_args, ctx) {
return {
content: [
{
type: "text",
text: ctx.signal.aborted ? "x" : "ok",
},
],
};
},
});
const r2 = await server.invokeTool("fast", {}, { signal: ac.signal });
expect(r2.content[0]).toMatchObject({ text: "ok" });
});
it("server invokeTool with paramsSchema replaces params with parsed data", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "sum",
inputSchema: {},
paramsSchema: z.default.object({
a: z.default.number(),
b: z.default.number(),
}),
async handler(args) {
const { a, b } = args as { a: number; b: number };
return {
content: [{ type: "text", text: String(a + b) }],
};
},
});
const result = await server.invokeTool("sum", { a: 2, b: 3 });
expect(result.content[0]).toMatchObject({ text: "5" });
});
it("server initialized getter starts false and flips true after handshake", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
expect(server.initialized).toBe(false);
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
expect(server.initialized).toBe(true);
await client.disconnect();
await server.stop();
});
it("server rejects initialize for unsupported protocol version", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
await expect(
peer.request("initialize", {
protocolVersion: "9999-01-01",
capabilities: {},
clientInfo: { name: "c", version: "0.1.0" },
})
).rejects.toMatchObject({
code: MCP_CODES.INTERNAL_ERROR,
});
await peer.close();
await server.stop();
});
it("tool handler returning a ContentItem array is normalised", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "arr",
inputSchema: {},
async handler() {
return [{ type: "text" as const, text: "x" }];
},
});
const result = await server.invokeTool("arr", {});
expect(result.content).toEqual([{ type: "text", text: "x" }]);
expect(result.isError).toBeUndefined();
});
it("server resource handler returning array passes through", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://multi",
name: "multi",
async handler() {
return [
{ uri: "mem://multi", text: "part1" },
{ uri: "mem://multi", text: "part2" },
];
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const out = (await peer.request("resources/read", {
uri: "mem://multi",
})) as { contents: unknown[] };
expect(out.contents.length).toBe(2);
await peer.close();
await server.stop();
});
it("prompts/get over the wire surfaces handler description", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerPrompt({
name: "with-desc",
description: "a documented prompt",
async handler() {
return [
{
role: "user" as const,
content: { type: "text" as const, text: "hi" },
},
];
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const out = (await peer.request("prompts/get", {
name: "with-desc",
})) as { description?: string; messages: unknown[] };
expect(out.description).toBe("a documented prompt");
expect(out.messages.length).toBe(1);
await peer.close();
await server.stop();
});
it("server unregisterTool without dispatcher does not notify", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "z",
inputSchema: {},
async handler() {
return { content: [] };
},
});
expect(server.unregisterTool("z")).toBe(true);
expect(server.unregisterTool("z")).toBe(false);
});
it("unregister returns false for unknown resource/prompt names", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
expect(server.unregisterResource("mem://missing")).toBe(false);
expect(server.unregisterPrompt("missing")).toBe(false);
});
it("list helpers surface optional description/mimeType/arguments", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://r",
name: "r",
description: "rd",
mimeType: "text/plain",
async handler() {
return { uri: "mem://r" };
},
});
server.registerPrompt({
name: "p",
description: "pd",
arguments: [{ name: "a" }],
async handler() {
return [];
},
});
server.registerTool({
name: "t",
description: "td",
inputSchema: {},
async handler() {
return { content: [] };
},
});
const resources = server.listResources();
expect(resources[0]?.description).toBe("rd");
expect(resources[0]?.mimeType).toBe("text/plain");
const prompts = server.listPrompts();
expect(prompts[0]?.description).toBe("pd");
expect(prompts[0]?.arguments).toEqual([{ name: "a" }]);
const tools = server.listTools();
expect(tools[0]?.description).toBe("td");
});
it("markResourceUpdated before start is a silent no-op", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.markResourceUpdated("mem://x");
});
it("server invokeTool wraps non-Error handler throws into isError", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "throwsString",
inputSchema: {},
async handler() {
const v: unknown = "raw-string";
throw v;
},
});
const result = await server.invokeTool("throwsString", {});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({ text: "Error: raw-string" });
});
it("server readResource returns array handler output unwrapped", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://multi",
name: "multi",
async handler() {
return [
{ uri: "mem://multi", text: "a" },
{ uri: "mem://multi", text: "b" },
];
},
});
const out = await server.readResource("mem://multi");
expect(out.length).toBe(2);
});
it("server start wires injected clock and idGenerator", {
timeout: 5000,
}, async () => {
let n = 0;
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
clock: () => 4242,
idGenerator: () => {
n += 1;
return `s-${n}`;
},
});
const [t] = InMemoryTransport.pair();
await server.start(t);
expect(server.running).toBe(true);
await server.stop();
});
});

View File

@@ -0,0 +1,254 @@
/**
* MCPServer wire-side edge-case coverage — exercises branches in the
* dispatcher-mounted JSON-RPC handlers and helpers: `tools/call` with no
* arguments / no `_meta`, signal forwarding to `createSamplingMessage` /
* `listClientRoots`, `emitLog` without a logger, authorizer accept-branch,
* invokeTool progress + abort listeners, Zod root-level rejection.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MCPClient } from "../client/mcp-client/mcp-client.js";
import { MCP_CODES } from "../error-codes/error-codes.js";
import { JsonRpcDispatcher } from "../jsonrpc/dispatcher/dispatcher.js";
import { MCPServer } from "../server/mcp-server/mcp-server.js";
import { InMemoryTransport } from "../transport/in-memory/in-memory.js";
describe("server wire edge cases", () => {
it("tools/call without arguments uses empty-object schema input", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "no-arg",
inputSchema: {},
paramsSchema: z.default.object({}).optional(),
async handler() {
return { content: [{ type: "text", text: "ok" }] };
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const out = (await peer.request("tools/call", {
name: "no-arg",
})) as { content: Array<{ text: string }> };
expect(out.content[0]?.text).toBe("ok");
await peer.close();
await server.stop();
});
it("tools/call without _meta has undefined progressToken", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "no-meta",
inputSchema: {},
async handler(_args, ctx) {
ctx.progress(1, 2);
return { content: [{ type: "text", text: "done" }] };
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const out = (await peer.request("tools/call", {
name: "no-meta",
arguments: {},
})) as { content: Array<{ text: string }> };
expect(out.content[0]?.text).toBe("done");
await peer.close();
await server.stop();
});
it("createSamplingMessage and listClientRoots forward signal", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const client = new MCPClient({
info: { name: "c", version: "0.1.0" },
});
client.setSamplingProvider(async () => ({
role: "assistant" as const,
content: { type: "text" as const, text: "ok" },
model: "demo",
}));
client.setRootsProvider(async () => [{ uri: "file:///x" }]);
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const ac = new AbortController();
const r1 = await server.createSamplingMessage(
{
messages: [
{
role: "user",
content: { type: "text", text: "hi" },
},
],
maxTokens: 10,
},
{ signal: ac.signal }
);
expect(r1.role).toBe("assistant");
const r2 = await server.listClientRoots({ signal: ac.signal });
expect(r2.length).toBe(1);
await client.disconnect();
await server.stop();
});
it("emitLog without logger argument omits the logger field", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const observed: Array<{ logger?: string }> = [];
peer.onNotification("notifications/message", (params) =>
observed.push(params as { logger?: string })
);
server.emitLog("info", { x: 1 });
await new Promise((r) => setTimeout(r, 10));
expect(observed[0]?.logger).toBeUndefined();
await peer.close();
await server.stop();
});
it("authorize hook accepting the call passes the throw branch", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.setAuthorizer(async () => true);
server.registerTool({
name: "ok",
inputSchema: {},
async handler() {
return { content: [{ type: "text", text: "yes" }] };
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
const peer = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "X",
});
const result = (await peer.request("tools/call", {
name: "ok",
arguments: {},
})) as { content: Array<{ text: string }> };
expect(result.content[0]?.text).toBe("yes");
await peer.close();
await server.stop();
});
it("invokeTool handler progress and abort-listener fire", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "p",
inputSchema: {},
async handler(_args, ctx) {
ctx.progress(1, 2);
ctx.progress(2, 2);
return { content: [{ type: "text", text: "done" }] };
},
});
const ac = new AbortController();
const result = await server.invokeTool("p", {}, { signal: ac.signal });
expect(result.content[0]).toMatchObject({ text: "done" });
ac.abort();
});
it("server readResource and getPromptMessages with aborting signal", {
timeout: 5000,
}, async () => {
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerResource({
uri: "mem://r",
name: "r",
async handler() {
return { uri: "mem://r", text: "" };
},
});
server.registerPrompt({
name: "p",
async handler() {
return [];
},
});
const ac = new AbortController();
await server.readResource("mem://r", { signal: ac.signal });
await server.getPromptMessages("p", {}, { signal: ac.signal });
ac.abort();
});
it("Zod root-level rejection formats as `(root)` in error message", {
timeout: 5000,
}, async () => {
const z = await import("zod");
const server = new MCPServer({
info: { name: "s", version: "0.1.0" },
});
server.registerTool({
name: "needs-string",
inputSchema: {},
paramsSchema: z.default.string(),
async handler() {
return { content: [] };
},
});
await expect(
server.invokeTool("needs-string", { not: "a-string" })
).rejects.toMatchObject({ code: MCP_CODES.INVALID_PARAMS });
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({
side: "server",
transport: a,
dispatcherId: "L",
paramSchemas: {
strictly: z.default.string(),
},
});
const right = new JsonRpcDispatcher({
side: "client",
transport: b,
dispatcherId: "R",
});
left.registerHandler("strictly", async () => ({}));
await expect(
right.request("strictly", { not: "a-string" })
).rejects.toMatchObject({ code: MCP_CODES.INVALID_PARAMS });
await left.close();
await right.close();
});
});