mcp-server.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { createEngine } from "@nengine/core";
  6. import type { Engine, TimeSystem } from "@nengine/core";
  7. import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  8. import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
  9. import { syncContentWrite } from "../src/edit-engine.js";
  10. import { createEditorMcpHttpServer, resolveEditorMcpDirs } from "../src/mcp-server.js";
  11. import type { EditorMcpHttpHandle } from "../src/mcp-server.js";
  12. const TIME_YAML = [
  13. "id: default",
  14. "dayLengthTicks: 480",
  15. "seasons:",
  16. " - { name: spring, dayCount: 28 }",
  17. "start: { day: 1, season: spring }",
  18. "",
  19. ].join("\n");
  20. const FARM_YAML = [
  21. "id: farm",
  22. "width: 2",
  23. "height: 2",
  24. 'tilesetRef: "tiles"',
  25. "spawn: { x: 0, y: 0 }",
  26. "layers:",
  27. " - name: ground",
  28. " data:",
  29. " - [a, b]",
  30. " - [b, a]",
  31. "",
  32. ].join("\n");
  33. const HOE_DEF = { id: "hoe", name: "Hoe", kind: "tool", stackable: false };
  34. const CARROT_YAML = ["id: carrot", "name: Carrot", "kind: material", "stackable: true", ""].join("\n");
  35. interface Harness {
  36. tmp: string;
  37. engine: Engine;
  38. http: EditorMcpHttpHandle;
  39. client: Client;
  40. close: () => Promise<void>;
  41. }
  42. async function setup(): Promise<Harness> {
  43. const tmp = await mkdtemp(join(tmpdir(), "editor-mcp-"));
  44. const content = join(tmp, "content");
  45. await mkdir(join(content, "maps"), { recursive: true });
  46. await writeFile(join(content, "time.yaml"), TIME_YAML);
  47. await writeFile(join(content, "maps", "farm.yaml"), FARM_YAML);
  48. const http = await createEditorMcpHttpServer();
  49. const engine = createEngine({ contentDir: content });
  50. await engine.start();
  51. await http.attach(engine);
  52. const client = new Client({ name: "editor-mcp-http-test", version: "0.0.1" });
  53. await client.connect(new StreamableHTTPClientTransport(new URL(http.url)));
  54. return {
  55. tmp,
  56. engine,
  57. http,
  58. client,
  59. close: async () => {
  60. await client.close();
  61. await http.close();
  62. await engine.stop();
  63. await rm(tmp, { recursive: true, force: true });
  64. },
  65. };
  66. }
  67. function textOf(result: { content: { type: string; text?: string }[] }): string {
  68. return result.content.find((block) => block.type === "text")?.text ?? "";
  69. }
  70. function jsonOf(text: string): Record<string, unknown> {
  71. return JSON.parse(text) as Record<string, unknown>;
  72. }
  73. describe("editor embedded MCP server (streamable HTTP over the editing engine)", () => {
  74. it("starts with a /mcp URL and reports running=false before any engine is attached", async () => {
  75. const http = await createEditorMcpHttpServer();
  76. try {
  77. expect(http.running).toBe(false);
  78. expect(http.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
  79. const res = await fetch(http.url);
  80. expect(res.status).toBe(503);
  81. } finally {
  82. await http.close();
  83. }
  84. });
  85. it("attach makes running true and serves the handshake with the editor server identity", async () => {
  86. const { http, client, close } = await setup();
  87. try {
  88. expect(http.running).toBe(true);
  89. expect(client.getServerVersion()).toMatchObject({ name: "nengine-editor-mcp", version: "0.1.0" });
  90. } finally {
  91. await close();
  92. }
  93. });
  94. it("listTools over real HTTP returns the full 124-tool surface", async () => {
  95. const { client, close } = await setup();
  96. try {
  97. const tools = await client.listTools();
  98. expect(tools.tools).toHaveLength(124);
  99. for (const tool of tools.tools) {
  100. expect(tool.inputSchema).toMatchObject({ type: "object" });
  101. }
  102. const names = tools.tools.map((tool) => tool.name);
  103. for (const expected of ["item.create", "map.get", "time.advance", "server.info", "scene.validate", "npc.path"]) {
  104. expect(names).toContain(expected);
  105. }
  106. } finally {
  107. await close();
  108. }
  109. });
  110. it("listResources/listPrompts over HTTP expose the 36 resources and 17 prompts", async () => {
  111. const { client, close } = await setup();
  112. try {
  113. const resources = await client.listResources();
  114. expect(resources.resources).toHaveLength(36);
  115. const prompts = await client.listPrompts();
  116. expect(prompts.prompts).toHaveLength(17);
  117. } finally {
  118. await close();
  119. }
  120. });
  121. it("item.create over HTTP writes content/items/<id>.yaml AND registers live in the shared registry", async () => {
  122. const { engine, tmp, client, close } = await setup();
  123. try {
  124. const result = await client.callTool({ name: "item.create", arguments: { def: HOE_DEF } });
  125. expect(result.isError).not.toBe(true);
  126. expect(jsonOf(textOf(result))).toMatchObject(HOE_DEF);
  127. const file = await readFile(join(tmp, "content/items/hoe.yaml"), "utf8");
  128. expect(file).toContain("id: hoe");
  129. expect(engine.registry.get("item", "hoe")).toMatchObject(HOE_DEF);
  130. const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
  131. expect((listed.items as { id: string }[]).map((item) => item.id)).toContain("hoe");
  132. } finally {
  133. await close();
  134. }
  135. });
  136. it("time.advance over HTTP mutates the instance's own world", async () => {
  137. const { engine, client, close } = await setup();
  138. try {
  139. const advanced = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
  140. expect(advanced).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  141. const time = engine.systems.get<TimeSystem>("time");
  142. expect(time?.get()).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  143. const viaTool = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
  144. expect(viaTool).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  145. } finally {
  146. await close();
  147. }
  148. });
  149. it("dual-channel GUI→MCP: a file the GUI writes is visible to MCP after syncContentWrite", async () => {
  150. const { engine, tmp, client, close } = await setup();
  151. try {
  152. await mkdir(join(tmp, "content/items"), { recursive: true });
  153. await writeFile(join(tmp, "content/items/carrot.yaml"), CARROT_YAML);
  154. await syncContentWrite(engine, tmp, "content/items/carrot.yaml");
  155. const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
  156. const ids = (listed.items as { id: string }[]).map((item) => item.id);
  157. expect(ids).toContain("carrot");
  158. expect(engine.registry.get("item", "carrot")).toMatchObject({ id: "carrot", kind: "material" });
  159. } finally {
  160. await close();
  161. }
  162. });
  163. it("dual-channel: an invalid GUI-written file leaves the registry untouched", async () => {
  164. const { engine, tmp, close } = await setup();
  165. try {
  166. await mkdir(join(tmp, "content/items"), { recursive: true });
  167. await writeFile(join(tmp, "content/items/broken.yaml"), "id: broken\nname: [unclosed\n", "utf8");
  168. await syncContentWrite(engine, tmp, "content/items/broken.yaml");
  169. expect(engine.registry.get("item", "broken")).toBeUndefined();
  170. } finally {
  171. await close();
  172. }
  173. });
  174. it("project switch: detach/attach keeps the same URL and serves the new engine's content", async () => {
  175. const { engine, http, client, close } = await setup();
  176. const tmp2 = await mkdtemp(join(tmpdir(), "editor-mcp2-"));
  177. try {
  178. const content2 = join(tmp2, "content");
  179. await mkdir(join(content2, "maps"), { recursive: true });
  180. await writeFile(join(content2, "time.yaml"), TIME_YAML);
  181. await writeFile(join(content2, "maps", "farm.yaml"), FARM_YAML);
  182. const engine2 = createEngine({ contentDir: content2 });
  183. await engine2.start();
  184. const urlBefore = http.url;
  185. await http.detach();
  186. expect(http.running).toBe(false);
  187. await http.attach(engine2);
  188. expect(http.running).toBe(true);
  189. expect(http.url).toBe(urlBefore);
  190. const client2 = new Client({ name: "editor-mcp-http-test-2", version: "0.0.1" });
  191. await client2.connect(new StreamableHTTPClientTransport(new URL(http.url)));
  192. try {
  193. const created = await client2.callTool({ name: "item.create", arguments: { def: HOE_DEF } });
  194. expect(created.isError).not.toBe(true);
  195. expect(engine2.registry.get("item", "hoe")).toMatchObject(HOE_DEF);
  196. expect(engine.registry.get("item", "hoe")).toBeUndefined();
  197. } finally {
  198. await client2.close();
  199. await engine2.stop();
  200. }
  201. } finally {
  202. await close();
  203. await rm(tmp2, { recursive: true, force: true });
  204. }
  205. });
  206. it("error paths over HTTP: unknown tool and missing required args", async () => {
  207. const { client, close } = await setup();
  208. try {
  209. const unknown = await client.callTool({ name: "nope.nothing", arguments: {} });
  210. expect(unknown.isError).toBe(true);
  211. expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/);
  212. const missing = await client.callTool({ name: "time.advance", arguments: {} });
  213. expect(missing.isError).toBe(true);
  214. expect(textOf(missing)).toMatch(/missing required arguments: ticks/);
  215. } finally {
  216. await close();
  217. }
  218. });
  219. it("non-/mcp paths 404 and the OPTIONS preflight is answered", async () => {
  220. const http = await createEditorMcpHttpServer();
  221. try {
  222. const { origin } = new URL(http.url);
  223. const res = await fetch(`${origin}/other`);
  224. expect(res.status).toBe(404);
  225. const preflight = await fetch(http.url, {
  226. method: "OPTIONS",
  227. headers: { Origin: "http://localhost", "Access-Control-Request-Method": "POST" },
  228. });
  229. expect(preflight.status).toBe(204);
  230. expect(preflight.headers.get("access-control-allow-origin")).toBe("*");
  231. } finally {
  232. await http.close();
  233. }
  234. });
  235. it("resolveEditorMcpDirs: --dir maps content/<dir>/content and mods/<dir>/mods; no --dir errors", () => {
  236. const viaDir = resolveEditorMcpDirs({ argv: ["--dir", "/proj"], cwd: "/x" });
  237. expect(viaDir.contentDir).toBe("/proj/content");
  238. expect(viaDir.modsDir).toBeUndefined();
  239. const viaContent = resolveEditorMcpDirs({ argv: ["--content-dir", "content", "--mods-dir", "mods"], cwd: "/proj" });
  240. expect(viaContent.contentDir).toBe("/proj/content");
  241. expect(viaContent.modsDir).toBe("/proj/mods");
  242. expect(() => resolveEditorMcpDirs({ argv: [], cwd: "/x" })).toThrow(/--mcp-stdio requires --dir/);
  243. });
  244. });