import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine } from "@nengine/core"; import type { Engine, TimeSystem } from "@nengine/core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { syncContentWrite } from "../src/edit-engine.js"; import { createEditorMcpHttpServer, resolveEditorMcpDirs } from "../src/mcp-server.js"; import type { EditorMcpHttpHandle } from "../src/mcp-server.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"); const HOE_DEF = { id: "hoe", name: "Hoe", kind: "tool", stackable: false }; const CARROT_YAML = ["id: carrot", "name: Carrot", "kind: material", "stackable: true", ""].join("\n"); interface Harness { tmp: string; engine: Engine; http: EditorMcpHttpHandle; client: Client; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "editor-mcp-")); const content = join(tmp, "content"); await mkdir(join(content, "maps"), { recursive: true }); await writeFile(join(content, "time.yaml"), TIME_YAML); await writeFile(join(content, "maps", "farm.yaml"), FARM_YAML); const http = await createEditorMcpHttpServer(); const engine = createEngine({ contentDir: content }); await engine.start(); await http.attach(engine); const client = new Client({ name: "editor-mcp-http-test", version: "0.0.1" }); await client.connect(new StreamableHTTPClientTransport(new URL(http.url))); return { tmp, engine, http, client, close: async () => { await client.close(); await http.close(); await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function textOf(result: { content?: { type: string; text?: string }[] }): string { return result.content?.find((block) => block.type === "text")?.text ?? ""; } function jsonOf(text: string): Record { return JSON.parse(text) as Record; } describe("editor embedded MCP server (streamable HTTP over the editing engine)", () => { it("starts with a /mcp URL and reports running=false before any engine is attached", async () => { const http = await createEditorMcpHttpServer(); try { expect(http.running).toBe(false); expect(http.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); const res = await fetch(http.url); expect(res.status).toBe(503); } finally { await http.close(); } }); it("attach makes running true and serves the handshake with the editor server identity", async () => { const { http, client, close } = await setup(); try { expect(http.running).toBe(true); expect(client.getServerVersion()).toMatchObject({ name: "nengine-editor-mcp", version: "0.1.0" }); } finally { await close(); } }); it("listTools over real HTTP returns the full 132-tool surface", async () => { const { client, close } = await setup(); try { const tools = await client.listTools(); expect(tools.tools).toHaveLength(132); for (const tool of tools.tools) { expect(tool.inputSchema).toMatchObject({ type: "object" }); } const names = tools.tools.map((tool) => tool.name); for (const expected of ["item.create", "item.use", "map.get", "time.advance", "server.info", "scene.validate", "npc.path", "craft.do_craft"]) { expect(names).toContain(expected); } } finally { await close(); } }); it("listResources/listPrompts over HTTP expose the 36 resources and 17 prompts", async () => { const { client, close } = await setup(); try { const resources = await client.listResources(); expect(resources.resources).toHaveLength(36); const prompts = await client.listPrompts(); expect(prompts.prompts).toHaveLength(17); } finally { await close(); } }); it("item.create over HTTP writes content/items/.yaml AND registers live in the shared registry", async () => { const { engine, tmp, client, close } = await setup(); try { const result = await client.callTool({ name: "item.create", arguments: { def: HOE_DEF } }); expect(result.isError).not.toBe(true); expect(jsonOf(textOf(result))).toMatchObject(HOE_DEF); const file = await readFile(join(tmp, "content/items/hoe.yaml"), "utf8"); expect(file).toContain("id: hoe"); expect(engine.registry.get("item", "hoe")).toMatchObject(HOE_DEF); const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} }))); expect((listed.items as { id: string }[]).map((item) => item.id)).toContain("hoe"); } finally { await close(); } }); it("time.advance over HTTP mutates the instance's own world", async () => { const { engine, client, close } = await setup(); try { const advanced = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } }))); expect(advanced).toEqual({ day: 2, season: "spring", tickInDay: 0 }); const time = engine.systems.get("time"); expect(time?.get()).toEqual({ day: 2, season: "spring", tickInDay: 0 }); const viaTool = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} }))); expect(viaTool).toEqual({ day: 2, season: "spring", tickInDay: 0 }); } finally { await close(); } }); it("dual-channel GUI→MCP: a file the GUI writes is visible to MCP after syncContentWrite", async () => { const { engine, tmp, client, close } = await setup(); try { await mkdir(join(tmp, "content/items"), { recursive: true }); await writeFile(join(tmp, "content/items/carrot.yaml"), CARROT_YAML); await syncContentWrite(engine, tmp, "content/items/carrot.yaml"); const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} }))); const ids = (listed.items as { id: string }[]).map((item) => item.id); expect(ids).toContain("carrot"); expect(engine.registry.get("item", "carrot")).toMatchObject({ id: "carrot", kind: "material" }); } finally { await close(); } }); it("dual-channel: an invalid GUI-written file leaves the registry untouched", async () => { const { engine, tmp, close } = await setup(); try { await mkdir(join(tmp, "content/items"), { recursive: true }); await writeFile(join(tmp, "content/items/broken.yaml"), "id: broken\nname: [unclosed\n", "utf8"); await syncContentWrite(engine, tmp, "content/items/broken.yaml"); expect(engine.registry.get("item", "broken")).toBeUndefined(); } finally { await close(); } }); it("project switch: detach/attach keeps the same URL and serves the new engine's content", async () => { const { engine, http, client, close } = await setup(); const tmp2 = await mkdtemp(join(tmpdir(), "editor-mcp2-")); try { const content2 = join(tmp2, "content"); await mkdir(join(content2, "maps"), { recursive: true }); await writeFile(join(content2, "time.yaml"), TIME_YAML); await writeFile(join(content2, "maps", "farm.yaml"), FARM_YAML); const engine2 = createEngine({ contentDir: content2 }); await engine2.start(); const urlBefore = http.url; await http.detach(); expect(http.running).toBe(false); await http.attach(engine2); expect(http.running).toBe(true); expect(http.url).toBe(urlBefore); const client2 = new Client({ name: "editor-mcp-http-test-2", version: "0.0.1" }); await client2.connect(new StreamableHTTPClientTransport(new URL(http.url))); try { const created = await client2.callTool({ name: "item.create", arguments: { def: HOE_DEF } }); expect(created.isError).not.toBe(true); expect(engine2.registry.get("item", "hoe")).toMatchObject(HOE_DEF); expect(engine.registry.get("item", "hoe")).toBeUndefined(); } finally { await client2.close(); await engine2.stop(); } } finally { await close(); await rm(tmp2, { recursive: true, force: true }); } }); it("error paths over HTTP: unknown tool and missing required args", async () => { const { client, close } = await setup(); try { const unknown = await client.callTool({ name: "nope.nothing", arguments: {} }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/); const missing = await client.callTool({ name: "time.advance", arguments: {} }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/missing required arguments: ticks/); } finally { await close(); } }); it("non-/mcp paths 404 and the OPTIONS preflight is answered", async () => { const http = await createEditorMcpHttpServer(); try { const { origin } = new URL(http.url); const res = await fetch(`${origin}/other`); expect(res.status).toBe(404); const preflight = await fetch(http.url, { method: "OPTIONS", headers: { Origin: "http://localhost", "Access-Control-Request-Method": "POST" }, }); expect(preflight.status).toBe(204); expect(preflight.headers.get("access-control-allow-origin")).toBe("*"); } finally { await http.close(); } }); it("resolveEditorMcpDirs: --dir maps content//content and mods//mods; no --dir errors", () => { const norm = (p: string) => p.split("\\").join("/").replace(/^([A-Z]):/i, ""); const viaDir = resolveEditorMcpDirs({ argv: ["--dir", "/proj"], cwd: "/x" }); expect(norm(viaDir.contentDir)).toBe("/proj/content"); expect(viaDir.modsDir).toBeUndefined(); const viaContent = resolveEditorMcpDirs({ argv: ["--content-dir", "content", "--mods-dir", "mods"], cwd: "/proj" }); expect(norm(viaContent.contentDir)).toBe("/proj/content"); expect(norm(viaContent.modsDir ?? "")).toBe("/proj/mods"); expect(() => resolveEditorMcpDirs({ argv: [], cwd: "/x" })).toThrow(/--mcp-stdio requires --dir/); }); });