| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- import { describe, expect, it } from "vitest";
- import { McpGateway, McpGatewayError } from "../src/gateway.js";
- import type { McpNamespace, McpToolResult } from "../src/namespace.js";
- function makeNamespace(name: string, toolNames: string[], enabled = true): McpNamespace {
- return {
- name,
- enabled,
- tools: toolNames.map((toolName) => ({
- name: toolName,
- description: `tool ${toolName}`,
- inputSchema: { type: "object", properties: {} },
- handler: (args): McpToolResult => ({
- content: [{ type: "text", text: JSON.stringify({ ns: name, tool: toolName, args }) }],
- }),
- })),
- resources: [],
- prompts: [],
- };
- }
- function makeResourceNamespace(): McpNamespace {
- return {
- name: "map",
- enabled: true,
- tools: [],
- resources: [
- {
- uri: "map://{mapId}",
- name: "map definition",
- read: (params) => ({ contents: [{ uri: `map://${params?.["mapId"]}`, text: `map:${params?.["mapId"]}` }] }),
- },
- {
- uri: "server://status",
- name: "server status",
- read: () => ({ contents: [{ uri: "server://status", text: "status-ok" }] }),
- },
- ],
- prompts: [
- {
- name: "orient",
- description: "onboard",
- get: () => ({ messages: [{ role: "assistant", content: { type: "text", text: "orientation" } }] }),
- },
- ],
- };
- }
- async function toolText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<string> {
- const result = await gateway.callTool(name, args);
- return result.content[0]?.text ?? "";
- }
- describe("McpGateway", () => {
- it("registers namespaces and reports status", () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info"]));
- gateway.register(makeNamespace("map", ["list", "get"]));
- expect(gateway.namespaces().map((ns) => ns.name)).toEqual(["server", "map"]);
- expect(gateway.status()).toEqual([
- { name: "server", enabled: true },
- { name: "map", enabled: true },
- ]);
- gateway.disable("map");
- expect(gateway.status()).toEqual([
- { name: "server", enabled: true },
- { name: "map", enabled: false },
- ]);
- gateway.enable("map");
- expect(gateway.status()[1]?.enabled).toBe(true);
- });
- it("rejects duplicate registration", () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", []));
- expect(() => gateway.register(makeNamespace("server", []))).toThrow(/already registered/);
- });
- it("lists tools across namespaces with dotted names", () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info", "status"]));
- gateway.register(makeNamespace("map", ["list"]));
- expect(gateway.listTools().map((tool) => tool.name)).toEqual(["server.info", "server.status", "map.list"]);
- expect(gateway.listTools()[0]?.description).toBe("tool info");
- });
- it("routes callTool to the owning namespace and passes arguments", async () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info", "status"]));
- gateway.register(makeNamespace("map", ["list"]));
- const text = await toolText(gateway, "map.list", { extra: 1 });
- expect(JSON.parse(text)).toEqual({ ns: "map", tool: "list", args: { extra: 1 } });
- });
- it("returns an isError result for unknown tools", async () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info"]));
- const result = await gateway.callTool("map.list", {});
- expect(result.isError).toBe(true);
- expect(result.content[0]?.text).toMatch(/tool not found: map\.list/);
- });
- it("validates required inputSchema arguments", async () => {
- const gateway = new McpGateway();
- gateway.register({
- name: "time",
- enabled: true,
- tools: [
- {
- name: "advance",
- description: "advance",
- inputSchema: {
- type: "object",
- properties: { ticks: { type: "number" } },
- required: ["ticks"],
- },
- handler: (args) => ({ content: [{ type: "text", text: `advanced ${String(args["ticks"])}` }] }),
- },
- ],
- resources: [],
- prompts: [],
- });
- const ok = await gateway.callTool("time.advance", { ticks: 3 });
- expect(ok.isError).toBeUndefined();
- expect(ok.content[0]?.text).toBe("advanced 3");
- const missing = await gateway.callTool("time.advance", {});
- expect(missing.isError).toBe(true);
- expect(missing.content[0]?.text).toMatch(/missing required arguments: ticks/);
- });
- it("excludes disabled namespaces from listings", () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info"]));
- gateway.register(makeNamespace("map", ["list", "get"]));
- gateway.disable("map");
- expect(gateway.listTools().map((tool) => tool.name)).toEqual(["server.info"]);
- });
- it("rejects tool calls on a disabled namespace with a clear error", async () => {
- const gateway = new McpGateway();
- gateway.register(makeNamespace("server", ["info"]));
- gateway.register(makeNamespace("map", ["list"]));
- gateway.disable("map");
- const result = await gateway.callTool("map.list", {});
- expect(result.isError).toBe(true);
- expect(result.content[0]?.text).toMatch(/namespace "map" is disabled/);
- });
- it("lists resources and routes concrete and template URIs", async () => {
- const gateway = new McpGateway();
- gateway.register(makeResourceNamespace());
- expect(gateway.listResources().map((resource) => resource.uri)).toEqual(["map://{mapId}", "server://status"]);
- const concrete = await gateway.readResource("server://status");
- expect(concrete.contents[0]?.text).toBe("status-ok");
- const templated = await gateway.readResource("map://farm");
- expect(templated.contents[0]).toEqual({ uri: "map://farm", text: "map:farm" });
- });
- it("throws for unknown resources", async () => {
- const gateway = new McpGateway();
- gateway.register(makeResourceNamespace());
- await expect(gateway.readResource("item://nope")).rejects.toThrow(McpGatewayError);
- await expect(gateway.readResource("item://nope")).rejects.toThrow(/resource not found/);
- });
- it("lists and gets prompts", async () => {
- const gateway = new McpGateway();
- gateway.register(makeResourceNamespace());
- expect(gateway.listPrompts().map((prompt) => prompt.name)).toEqual(["map.orient"]);
- const prompt = await gateway.getPrompt("map.orient", {});
- expect(prompt.messages[0]?.content.text).toBe("orientation");
- });
- it("throws for unknown prompts", async () => {
- const gateway = new McpGateway();
- gateway.register(makeResourceNamespace());
- await expect(gateway.getPrompt("server.nope", {})).rejects.toThrow(/prompt not found/);
- });
- it("blocks disabled namespaces across resources and prompts", async () => {
- const gateway = new McpGateway();
- gateway.register(makeResourceNamespace());
- gateway.disable("map");
- await expect(gateway.readResource("map://farm")).rejects.toThrow(/disabled/);
- await expect(gateway.getPrompt("map.orient", {})).rejects.toThrow(/disabled/);
- });
- });
|