roundtrip.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import { createEngine } from "@nengine/core";
  4. import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  5. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
  6. import { createNengineMcpServer, createGateway } from "../src/server.js";
  7. const repoRoot = resolve(process.cwd(), "../..");
  8. const contentDir = resolve(repoRoot, "content");
  9. const modsDir = resolve(repoRoot, "mods");
  10. interface Roundtrip {
  11. engine: ReturnType<typeof createEngine>;
  12. server: ReturnType<typeof createNengineMcpServer>;
  13. client: Client;
  14. close: () => Promise<void>;
  15. }
  16. async function setup(gateway?: ReturnType<typeof createGateway>): Promise<Roundtrip> {
  17. const engine = createEngine({ contentDir, modsDir });
  18. await engine.start();
  19. const server = gateway ? createNengineMcpServer(engine, gateway) : createNengineMcpServer(engine);
  20. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  21. await server.connect(serverTransport);
  22. const client = new Client({ name: "roundtrip-test", version: "0.0.1" });
  23. await client.connect(clientTransport);
  24. return {
  25. engine,
  26. server,
  27. client,
  28. close: async () => {
  29. await client.close();
  30. await server.close();
  31. await engine.stop();
  32. },
  33. };
  34. }
  35. function textOf(result: { content: { type: string; text?: string }[] }): string {
  36. return result.content.find((block) => block.type === "text")?.text ?? "";
  37. }
  38. function contentsOf(result: { contents: { text?: string }[] }): string {
  39. return result.contents[0]?.text ?? "";
  40. }
  41. describe("apps/mcp-server SDK roundtrip (in-memory transport)", () => {
  42. it("initialize, listTools, callTool, resources, and prompts roundtrip", async () => {
  43. const { client, close } = await setup();
  44. try {
  45. const tools = await client.listTools();
  46. const toolNames = tools.tools.map((tool) => tool.name);
  47. expect(toolNames).toEqual(
  48. expect.arrayContaining([
  49. "server.info",
  50. "server.status",
  51. "map.list",
  52. "map.get",
  53. "time.get",
  54. "time.advance",
  55. "time.set",
  56. ]),
  57. );
  58. const result = await client.callTool({ name: "map.get", arguments: {} });
  59. expect(result.isError).toBeUndefined();
  60. const map = JSON.parse(textOf(result)) as { id: string; player: { x: number; y: number } };
  61. expect(map.id).toBe("farm");
  62. expect(map.player.x).toBe(2);
  63. const advanced = await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
  64. const time = JSON.parse(textOf(advanced)) as { day: number };
  65. expect(time.day).toBe(2);
  66. const resources = await client.listResources();
  67. expect(resources.resources.map((resource) => resource.uri)).toEqual(
  68. expect.arrayContaining(["server://status", "time://state", "map://{mapId}"]),
  69. );
  70. const state = await client.readResource({ uri: "time://state" });
  71. expect(JSON.parse(contentsOf(state))).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  72. const prompts = await client.listPrompts();
  73. expect(prompts.prompts.map((prompt) => prompt.name)).toContain("server.orient");
  74. const orient = await client.getPrompt({ name: "server.orient", arguments: {} });
  75. expect(orient.messages[0]?.content).toMatchObject({ type: "text", text: expect.stringContaining("nEngine MCP server") });
  76. } finally {
  77. await close();
  78. }
  79. });
  80. it("disabled namespaces are absent from tools/list and fail on tools/call", async () => {
  81. const engine = createEngine({ contentDir, modsDir });
  82. await engine.start();
  83. const gateway = createGateway(engine);
  84. gateway.disable("time");
  85. const server = createNengineMcpServer(engine, gateway);
  86. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  87. await server.connect(serverTransport);
  88. const client = new Client({ name: "roundtrip-test", version: "0.0.1" });
  89. await client.connect(clientTransport);
  90. try {
  91. const tools = await client.listTools();
  92. expect(tools.tools.some((tool) => tool.name.startsWith("time."))).toBe(false);
  93. expect(tools.tools.some((tool) => tool.name === "map.get")).toBe(true);
  94. const rejected = await client.callTool({ name: "time.get", arguments: {} });
  95. expect(rejected.isError).toBe(true);
  96. expect(textOf(rejected)).toMatch(/namespace "time" is disabled/);
  97. } finally {
  98. await client.close();
  99. await server.close();
  100. await engine.stop();
  101. }
  102. });
  103. it("surfaces resource errors over the wire", async () => {
  104. const { client, close } = await setup();
  105. try {
  106. await expect(client.readResource({ uri: "map://does-not-exist" })).rejects.toThrow(/map not found/);
  107. } finally {
  108. await close();
  109. }
  110. });
  111. });