server-namespace.test.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { mkdtemp, mkdir, 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 { createEngineNamespaces, McpGateway } from "../src/index.js";
  7. const TIME_YAML = [
  8. "id: default",
  9. "dayLengthTicks: 480",
  10. "seasons:",
  11. " - { name: spring, dayCount: 28 }",
  12. "start: { day: 1, season: spring }",
  13. "",
  14. ].join("\n");
  15. const FARM_YAML = [
  16. "id: farm",
  17. "width: 2",
  18. "height: 2",
  19. 'tilesetRef: "tiles"',
  20. "spawn: { x: 0, y: 0 }",
  21. "layers:",
  22. " - name: ground",
  23. " data:",
  24. " - [a, b]",
  25. " - [b, a]",
  26. "",
  27. ].join("\n");
  28. interface Setup {
  29. engine: ReturnType<typeof createEngine>;
  30. gateway: McpGateway;
  31. tmp: string;
  32. close: () => Promise<void>;
  33. }
  34. async function setup(): Promise<Setup> {
  35. const tmp = await mkdtemp(join(tmpdir(), "farm-server-ns-"));
  36. await mkdir(join(tmp, "maps"), { recursive: true });
  37. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  38. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  39. const engine = createEngine({ contentDir: tmp });
  40. const gateway = new McpGateway();
  41. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  42. gateway.register(namespace);
  43. }
  44. await engine.start();
  45. return {
  46. engine,
  47. gateway,
  48. tmp,
  49. close: async () => {
  50. await engine.stop();
  51. await rm(tmp, { recursive: true, force: true });
  52. },
  53. };
  54. }
  55. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  56. ok: boolean;
  57. text: string;
  58. }> {
  59. const result = await gateway.callTool(name, args);
  60. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  61. }
  62. describe("server namespace subsystem toggles", () => {
  63. it("enable_subsystem/disable_subsystem reject non-string and empty names", async () => {
  64. const { gateway, close } = await setup();
  65. try {
  66. const missing = await callText(gateway, "server.enable_subsystem", {});
  67. expect(missing.ok).toBe(false);
  68. expect(missing.text).toMatch(/missing required arguments: name/);
  69. const empty = await callText(gateway, "server.disable_subsystem", { name: "" });
  70. expect(empty.ok).toBe(false);
  71. expect(empty.text).toBe("server.disable_subsystem: name must be a non-empty string");
  72. const nonString = await callText(gateway, "server.enable_subsystem", { name: 42 });
  73. expect(nonString.ok).toBe(false);
  74. expect(nonString.text).toBe("server.enable_subsystem: name must be a non-empty string");
  75. } finally {
  76. await close();
  77. }
  78. });
  79. it("enable_subsystem/disable_subsystem reject unknown subsystems", async () => {
  80. const { gateway, close } = await setup();
  81. try {
  82. const enableUnknown = await callText(gateway, "server.enable_subsystem", { name: "nope" });
  83. expect(enableUnknown.ok).toBe(false);
  84. expect(enableUnknown.text).toBe("server.enable_subsystem: unknown subsystem: nope");
  85. const disableUnknown = await callText(gateway, "server.disable_subsystem", { name: "nope" });
  86. expect(disableUnknown.ok).toBe(false);
  87. expect(disableUnknown.text).toBe("server.disable_subsystem: unknown subsystem: nope");
  88. } finally {
  89. await close();
  90. }
  91. });
  92. it("disable removes a subsystem's tools/resources/prompts; enable restores them", async () => {
  93. const { gateway, close } = await setup();
  94. try {
  95. const allTools = gateway.listTools().map((tool) => tool.name);
  96. expect(allTools).toContain("time.get");
  97. expect(allTools).toContain("crop.create");
  98. expect(allTools).toContain("server.info");
  99. expect(gateway.listPrompts().map((prompt) => prompt.name)).toContain("crop.create-prompt");
  100. const allResources = gateway.listResources().map((resource) => resource.uri);
  101. expect(allResources).toContain("time://state");
  102. const disabled = await callText(gateway, "server.disable_subsystem", { name: "time" });
  103. expect(disabled.ok).toBe(true);
  104. expect(JSON.parse(disabled.text)).toEqual({ name: "time", enabled: false });
  105. const afterTools = gateway.listTools().map((tool) => tool.name);
  106. expect(afterTools).not.toContain("time.get");
  107. expect(afterTools).not.toContain("time.advance");
  108. expect(afterTools).toContain("server.info");
  109. expect(gateway.listResources().map((resource) => resource.uri)).not.toContain("time://state");
  110. const rejected = await callText(gateway, "time.get");
  111. expect(rejected.ok).toBe(false);
  112. expect(rejected.text).toMatch(/namespace "time" is disabled/);
  113. const info = await callText(gateway, "server.info");
  114. expect(info.ok).toBe(true);
  115. const subsystems = (JSON.parse(info.text) as { subsystems: { name: string; enabled: boolean }[] }).subsystems;
  116. expect(subsystems.find((s) => s.name === "time")).toEqual({ name: "time", enabled: false });
  117. expect(subsystems.find((s) => s.name === "server")).toEqual({ name: "server", enabled: true });
  118. const disabledCrop = await callText(gateway, "server.disable_subsystem", { name: "crop" });
  119. expect(disabledCrop.ok).toBe(true);
  120. expect(gateway.listPrompts().map((prompt) => prompt.name)).not.toContain("crop.create-prompt");
  121. const enabledCrop = await callText(gateway, "server.enable_subsystem", { name: "crop" });
  122. expect(enabledCrop.ok).toBe(true);
  123. expect(gateway.listPrompts().map((prompt) => prompt.name)).toContain("crop.create-prompt");
  124. const enabled = await callText(gateway, "server.enable_subsystem", { name: "time" });
  125. expect(enabled.ok).toBe(true);
  126. expect(JSON.parse(enabled.text)).toEqual({ name: "time", enabled: true });
  127. const restored = gateway.listTools().map((tool) => tool.name);
  128. expect(restored).toContain("time.get");
  129. const get = await callText(gateway, "time.get");
  130. expect(get.ok).toBe(true);
  131. expect(JSON.parse(get.text)).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  132. } finally {
  133. await close();
  134. }
  135. });
  136. it("disable_subsystem rejects disabling the server namespace itself (guard rail)", async () => {
  137. const { gateway, close } = await setup();
  138. try {
  139. const guarded = await callText(gateway, "server.disable_subsystem", { name: "server" });
  140. expect(guarded.ok).toBe(false);
  141. expect(guarded.text).toBe("server.disable_subsystem: cannot disable the server subsystem");
  142. expect(gateway.listTools().map((tool) => tool.name)).toContain("server.info");
  143. const status = gateway.status().find((s) => s.name === "server");
  144. expect(status).toEqual({ name: "server", enabled: true });
  145. } finally {
  146. await close();
  147. }
  148. });
  149. it("enable/disable state is per-gateway in-memory and does not touch engine systems", async () => {
  150. const { engine, gateway, close } = await setup();
  151. try {
  152. await callText(gateway, "server.disable_subsystem", { name: "time" });
  153. const timeSystem = engine.systems.get("time");
  154. expect(timeSystem?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  155. expect(gateway.status().find((s) => s.name === "time")).toEqual({ name: "time", enabled: false });
  156. } finally {
  157. await close();
  158. }
  159. });
  160. });