Initial Commit
This commit is contained in:
@@ -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