445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|