| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273 |
- 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<void>;
- }
- async function setup(): Promise<Harness> {
- 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<string, unknown> {
- return JSON.parse(text) as Record<string, unknown>;
- }
- 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/<id>.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<TimeSystem>("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/<dir>/content and mods/<dir>/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/);
- });
- });
|