Initial Commit
This commit is contained in:
349
packages/devtools/tooling/mcp/src/e2e/client-edge-cases.test.ts
Normal file
349
packages/devtools/tooling/mcp/src/e2e/client-edge-cases.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
252
packages/devtools/tooling/mcp/src/e2e/handshake.test.ts
Normal file
252
packages/devtools/tooling/mcp/src/e2e/handshake.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
444
packages/devtools/tooling/mcp/src/e2e/server-edge-cases.test.ts
Normal file
444
packages/devtools/tooling/mcp/src/e2e/server-edge-cases.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user