gateway.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { describe, expect, it } from "vitest";
  2. import { McpGateway, McpGatewayError } from "../src/gateway.js";
  3. import type { McpNamespace, McpToolResult } from "../src/namespace.js";
  4. function makeNamespace(name: string, toolNames: string[], enabled = true): McpNamespace {
  5. return {
  6. name,
  7. enabled,
  8. tools: toolNames.map((toolName) => ({
  9. name: toolName,
  10. description: `tool ${toolName}`,
  11. inputSchema: { type: "object", properties: {} },
  12. handler: (args): McpToolResult => ({
  13. content: [{ type: "text", text: JSON.stringify({ ns: name, tool: toolName, args }) }],
  14. }),
  15. })),
  16. resources: [],
  17. prompts: [],
  18. };
  19. }
  20. function makeResourceNamespace(): McpNamespace {
  21. return {
  22. name: "map",
  23. enabled: true,
  24. tools: [],
  25. resources: [
  26. {
  27. uri: "map://{mapId}",
  28. name: "map definition",
  29. read: (params) => ({ contents: [{ uri: `map://${params?.["mapId"]}`, text: `map:${params?.["mapId"]}` }] }),
  30. },
  31. {
  32. uri: "server://status",
  33. name: "server status",
  34. read: () => ({ contents: [{ uri: "server://status", text: "status-ok" }] }),
  35. },
  36. ],
  37. prompts: [
  38. {
  39. name: "orient",
  40. description: "onboard",
  41. get: () => ({ messages: [{ role: "assistant", content: { type: "text", text: "orientation" } }] }),
  42. },
  43. ],
  44. };
  45. }
  46. async function toolText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<string> {
  47. const result = await gateway.callTool(name, args);
  48. return result.content[0]?.text ?? "";
  49. }
  50. describe("McpGateway", () => {
  51. it("registers namespaces and reports status", () => {
  52. const gateway = new McpGateway();
  53. gateway.register(makeNamespace("server", ["info"]));
  54. gateway.register(makeNamespace("map", ["list", "get"]));
  55. expect(gateway.namespaces().map((ns) => ns.name)).toEqual(["server", "map"]);
  56. expect(gateway.status()).toEqual([
  57. { name: "server", enabled: true },
  58. { name: "map", enabled: true },
  59. ]);
  60. gateway.disable("map");
  61. expect(gateway.status()).toEqual([
  62. { name: "server", enabled: true },
  63. { name: "map", enabled: false },
  64. ]);
  65. gateway.enable("map");
  66. expect(gateway.status()[1]?.enabled).toBe(true);
  67. });
  68. it("rejects duplicate registration", () => {
  69. const gateway = new McpGateway();
  70. gateway.register(makeNamespace("server", []));
  71. expect(() => gateway.register(makeNamespace("server", []))).toThrow(/already registered/);
  72. });
  73. it("lists tools across namespaces with dotted names", () => {
  74. const gateway = new McpGateway();
  75. gateway.register(makeNamespace("server", ["info", "status"]));
  76. gateway.register(makeNamespace("map", ["list"]));
  77. expect(gateway.listTools().map((tool) => tool.name)).toEqual(["server.info", "server.status", "map.list"]);
  78. expect(gateway.listTools()[0]?.description).toBe("tool info");
  79. });
  80. it("routes callTool to the owning namespace and passes arguments", async () => {
  81. const gateway = new McpGateway();
  82. gateway.register(makeNamespace("server", ["info", "status"]));
  83. gateway.register(makeNamespace("map", ["list"]));
  84. const text = await toolText(gateway, "map.list", { extra: 1 });
  85. expect(JSON.parse(text)).toEqual({ ns: "map", tool: "list", args: { extra: 1 } });
  86. });
  87. it("returns an isError result for unknown tools", async () => {
  88. const gateway = new McpGateway();
  89. gateway.register(makeNamespace("server", ["info"]));
  90. const result = await gateway.callTool("map.list", {});
  91. expect(result.isError).toBe(true);
  92. expect(result.content[0]?.text).toMatch(/tool not found: map\.list/);
  93. });
  94. it("validates required inputSchema arguments", async () => {
  95. const gateway = new McpGateway();
  96. gateway.register({
  97. name: "time",
  98. enabled: true,
  99. tools: [
  100. {
  101. name: "advance",
  102. description: "advance",
  103. inputSchema: {
  104. type: "object",
  105. properties: { ticks: { type: "number" } },
  106. required: ["ticks"],
  107. },
  108. handler: (args) => ({ content: [{ type: "text", text: `advanced ${String(args["ticks"])}` }] }),
  109. },
  110. ],
  111. resources: [],
  112. prompts: [],
  113. });
  114. const ok = await gateway.callTool("time.advance", { ticks: 3 });
  115. expect(ok.isError).toBeUndefined();
  116. expect(ok.content[0]?.text).toBe("advanced 3");
  117. const missing = await gateway.callTool("time.advance", {});
  118. expect(missing.isError).toBe(true);
  119. expect(missing.content[0]?.text).toMatch(/missing required arguments: ticks/);
  120. });
  121. it("excludes disabled namespaces from listings", () => {
  122. const gateway = new McpGateway();
  123. gateway.register(makeNamespace("server", ["info"]));
  124. gateway.register(makeNamespace("map", ["list", "get"]));
  125. gateway.disable("map");
  126. expect(gateway.listTools().map((tool) => tool.name)).toEqual(["server.info"]);
  127. });
  128. it("rejects tool calls on a disabled namespace with a clear error", async () => {
  129. const gateway = new McpGateway();
  130. gateway.register(makeNamespace("server", ["info"]));
  131. gateway.register(makeNamespace("map", ["list"]));
  132. gateway.disable("map");
  133. const result = await gateway.callTool("map.list", {});
  134. expect(result.isError).toBe(true);
  135. expect(result.content[0]?.text).toMatch(/namespace "map" is disabled/);
  136. });
  137. it("lists resources and routes concrete and template URIs", async () => {
  138. const gateway = new McpGateway();
  139. gateway.register(makeResourceNamespace());
  140. expect(gateway.listResources().map((resource) => resource.uri)).toEqual(["map://{mapId}", "server://status"]);
  141. const concrete = await gateway.readResource("server://status");
  142. expect(concrete.contents[0]?.text).toBe("status-ok");
  143. const templated = await gateway.readResource("map://farm");
  144. expect(templated.contents[0]).toEqual({ uri: "map://farm", text: "map:farm" });
  145. });
  146. it("throws for unknown resources", async () => {
  147. const gateway = new McpGateway();
  148. gateway.register(makeResourceNamespace());
  149. await expect(gateway.readResource("item://nope")).rejects.toThrow(McpGatewayError);
  150. await expect(gateway.readResource("item://nope")).rejects.toThrow(/resource not found/);
  151. });
  152. it("lists and gets prompts", async () => {
  153. const gateway = new McpGateway();
  154. gateway.register(makeResourceNamespace());
  155. expect(gateway.listPrompts().map((prompt) => prompt.name)).toEqual(["map.orient"]);
  156. const prompt = await gateway.getPrompt("map.orient", {});
  157. expect(prompt.messages[0]?.content.text).toBe("orientation");
  158. });
  159. it("throws for unknown prompts", async () => {
  160. const gateway = new McpGateway();
  161. gateway.register(makeResourceNamespace());
  162. await expect(gateway.getPrompt("server.nope", {})).rejects.toThrow(/prompt not found/);
  163. });
  164. it("blocks disabled namespaces across resources and prompts", async () => {
  165. const gateway = new McpGateway();
  166. gateway.register(makeResourceNamespace());
  167. gateway.disable("map");
  168. await expect(gateway.readResource("map://farm")).rejects.toThrow(/disabled/);
  169. await expect(gateway.getPrompt("map.orient", {})).rejects.toThrow(/disabled/);
  170. });
  171. });