@sammorrowdrums/mcpi-ext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.dockerignore +6 -0
  2. package/.github/dependabot.yml +25 -0
  3. package/.github/workflows/ci.yml +50 -0
  4. package/.hadolint.yaml +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +7 -0
  7. package/AGENTS.md +118 -0
  8. package/DECISIONS.md +99 -0
  9. package/Dockerfile +39 -0
  10. package/README.md +278 -0
  11. package/aube-lock.yaml +4146 -0
  12. package/dist/code-mode/code-mode-integration.test.d.ts +2 -0
  13. package/dist/code-mode/code-mode-integration.test.d.ts.map +1 -0
  14. package/dist/code-mode/code-mode-integration.test.js +116 -0
  15. package/dist/code-mode/code-mode-integration.test.js.map +1 -0
  16. package/dist/code-mode/eligibility.d.ts +10 -0
  17. package/dist/code-mode/eligibility.d.ts.map +1 -0
  18. package/dist/code-mode/eligibility.js +14 -0
  19. package/dist/code-mode/eligibility.js.map +1 -0
  20. package/dist/code-mode/eligibility.test.d.ts +2 -0
  21. package/dist/code-mode/eligibility.test.d.ts.map +1 -0
  22. package/dist/code-mode/eligibility.test.js +53 -0
  23. package/dist/code-mode/eligibility.test.js.map +1 -0
  24. package/dist/code-mode/executor.d.ts +35 -0
  25. package/dist/code-mode/executor.d.ts.map +1 -0
  26. package/dist/code-mode/executor.js +120 -0
  27. package/dist/code-mode/executor.js.map +1 -0
  28. package/dist/code-mode/executor.test.d.ts +2 -0
  29. package/dist/code-mode/executor.test.d.ts.map +1 -0
  30. package/dist/code-mode/executor.test.js +130 -0
  31. package/dist/code-mode/executor.test.js.map +1 -0
  32. package/dist/code-mode/index.d.ts +65 -0
  33. package/dist/code-mode/index.d.ts.map +1 -0
  34. package/dist/code-mode/index.js +175 -0
  35. package/dist/code-mode/index.js.map +1 -0
  36. package/dist/code-mode/tools.d.ts +45 -0
  37. package/dist/code-mode/tools.d.ts.map +1 -0
  38. package/dist/code-mode/tools.js +70 -0
  39. package/dist/code-mode/tools.js.map +1 -0
  40. package/dist/code-mode/type-hints.d.ts +20 -0
  41. package/dist/code-mode/type-hints.d.ts.map +1 -0
  42. package/dist/code-mode/type-hints.js +189 -0
  43. package/dist/code-mode/type-hints.js.map +1 -0
  44. package/dist/code-mode/type-hints.test.d.ts +2 -0
  45. package/dist/code-mode/type-hints.test.d.ts.map +1 -0
  46. package/dist/code-mode/type-hints.test.js +157 -0
  47. package/dist/code-mode/type-hints.test.js.map +1 -0
  48. package/dist/docker-e2e.d.ts +55 -0
  49. package/dist/docker-e2e.d.ts.map +1 -0
  50. package/dist/docker-e2e.js +235 -0
  51. package/dist/docker-e2e.js.map +1 -0
  52. package/dist/docker-e2e.test.d.ts +2 -0
  53. package/dist/docker-e2e.test.d.ts.map +1 -0
  54. package/dist/docker-e2e.test.js +176 -0
  55. package/dist/docker-e2e.test.js.map +1 -0
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +128 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.test.d.ts +2 -0
  61. package/dist/index.test.d.ts.map +1 -0
  62. package/dist/index.test.js +8 -0
  63. package/dist/index.test.js.map +1 -0
  64. package/dist/mcp/client-manager.d.ts +46 -0
  65. package/dist/mcp/client-manager.d.ts.map +1 -0
  66. package/dist/mcp/client-manager.js +148 -0
  67. package/dist/mcp/client-manager.js.map +1 -0
  68. package/dist/mcp/client-manager.test.d.ts +2 -0
  69. package/dist/mcp/client-manager.test.d.ts.map +1 -0
  70. package/dist/mcp/client-manager.test.js +204 -0
  71. package/dist/mcp/client-manager.test.js.map +1 -0
  72. package/dist/mcp/config-loader.d.ts +8 -0
  73. package/dist/mcp/config-loader.d.ts.map +1 -0
  74. package/dist/mcp/config-loader.js +43 -0
  75. package/dist/mcp/config-loader.js.map +1 -0
  76. package/dist/mcp/config-loader.test.d.ts +2 -0
  77. package/dist/mcp/config-loader.test.d.ts.map +1 -0
  78. package/dist/mcp/config-loader.test.js +93 -0
  79. package/dist/mcp/config-loader.test.js.map +1 -0
  80. package/dist/mcp/config.d.ts +46 -0
  81. package/dist/mcp/config.d.ts.map +1 -0
  82. package/dist/mcp/config.js +28 -0
  83. package/dist/mcp/config.js.map +1 -0
  84. package/dist/mcp/index.d.ts +5 -0
  85. package/dist/mcp/index.d.ts.map +1 -0
  86. package/dist/mcp/index.js +4 -0
  87. package/dist/mcp/index.js.map +1 -0
  88. package/dist/skills/discover.d.ts +10 -0
  89. package/dist/skills/discover.d.ts.map +1 -0
  90. package/dist/skills/discover.js +60 -0
  91. package/dist/skills/discover.js.map +1 -0
  92. package/dist/skills/format.d.ts +10 -0
  93. package/dist/skills/format.d.ts.map +1 -0
  94. package/dist/skills/format.js +38 -0
  95. package/dist/skills/format.js.map +1 -0
  96. package/dist/skills/format.test.d.ts +2 -0
  97. package/dist/skills/format.test.d.ts.map +1 -0
  98. package/dist/skills/format.test.js +63 -0
  99. package/dist/skills/format.test.js.map +1 -0
  100. package/dist/skills/index.d.ts +6 -0
  101. package/dist/skills/index.d.ts.map +1 -0
  102. package/dist/skills/index.js +6 -0
  103. package/dist/skills/index.js.map +1 -0
  104. package/dist/skills/load-skill-tool.d.ts +40 -0
  105. package/dist/skills/load-skill-tool.d.ts.map +1 -0
  106. package/dist/skills/load-skill-tool.js +104 -0
  107. package/dist/skills/load-skill-tool.js.map +1 -0
  108. package/dist/skills/mcp-tool-proxy.d.ts +11 -0
  109. package/dist/skills/mcp-tool-proxy.d.ts.map +1 -0
  110. package/dist/skills/mcp-tool-proxy.js +116 -0
  111. package/dist/skills/mcp-tool-proxy.js.map +1 -0
  112. package/dist/skills/skill-integration.test.d.ts +2 -0
  113. package/dist/skills/skill-integration.test.d.ts.map +1 -0
  114. package/dist/skills/skill-integration.test.js +119 -0
  115. package/dist/skills/skill-integration.test.js.map +1 -0
  116. package/dist/skills/skill-registry.d.ts +39 -0
  117. package/dist/skills/skill-registry.d.ts.map +1 -0
  118. package/dist/skills/skill-registry.js +48 -0
  119. package/dist/skills/skill-registry.js.map +1 -0
  120. package/dist/skills/skill-registry.test.d.ts +2 -0
  121. package/dist/skills/skill-registry.test.d.ts.map +1 -0
  122. package/dist/skills/skill-registry.test.js +67 -0
  123. package/dist/skills/skill-registry.test.js.map +1 -0
  124. package/dist/test-servers/weather-server.d.ts +12 -0
  125. package/dist/test-servers/weather-server.d.ts.map +1 -0
  126. package/dist/test-servers/weather-server.js +139 -0
  127. package/dist/test-servers/weather-server.js.map +1 -0
  128. package/dist/test-servers/weather-stdio.d.ts +2 -0
  129. package/dist/test-servers/weather-stdio.d.ts.map +1 -0
  130. package/dist/test-servers/weather-stdio.js +11 -0
  131. package/dist/test-servers/weather-stdio.js.map +1 -0
  132. package/dist/tool-cli/cli.d.ts +3 -0
  133. package/dist/tool-cli/cli.d.ts.map +1 -0
  134. package/dist/tool-cli/cli.js +179 -0
  135. package/dist/tool-cli/cli.js.map +1 -0
  136. package/dist/tool-cli/constants.d.ts +7 -0
  137. package/dist/tool-cli/constants.d.ts.map +1 -0
  138. package/dist/tool-cli/constants.js +15 -0
  139. package/dist/tool-cli/constants.js.map +1 -0
  140. package/dist/tool-cli/format.d.ts +8 -0
  141. package/dist/tool-cli/format.d.ts.map +1 -0
  142. package/dist/tool-cli/format.js +57 -0
  143. package/dist/tool-cli/format.js.map +1 -0
  144. package/dist/tool-cli/format.test.d.ts +2 -0
  145. package/dist/tool-cli/format.test.d.ts.map +1 -0
  146. package/dist/tool-cli/format.test.js +30 -0
  147. package/dist/tool-cli/format.test.js.map +1 -0
  148. package/dist/tool-cli/index.d.ts +4 -0
  149. package/dist/tool-cli/index.d.ts.map +1 -0
  150. package/dist/tool-cli/index.js +4 -0
  151. package/dist/tool-cli/index.js.map +1 -0
  152. package/dist/tool-cli/rpc-client.d.ts +6 -0
  153. package/dist/tool-cli/rpc-client.d.ts.map +1 -0
  154. package/dist/tool-cli/rpc-client.js +25 -0
  155. package/dist/tool-cli/rpc-client.js.map +1 -0
  156. package/dist/tool-cli/rpc-server.d.ts +48 -0
  157. package/dist/tool-cli/rpc-server.d.ts.map +1 -0
  158. package/dist/tool-cli/rpc-server.js +220 -0
  159. package/dist/tool-cli/rpc-server.js.map +1 -0
  160. package/dist/tool-cli/rpc-server.test.d.ts +2 -0
  161. package/dist/tool-cli/rpc-server.test.d.ts.map +1 -0
  162. package/dist/tool-cli/rpc-server.test.js +154 -0
  163. package/dist/tool-cli/rpc-server.test.js.map +1 -0
  164. package/eslint.config.js +27 -0
  165. package/images/banner.webp +0 -0
  166. package/images/code-c-maude.webp +0 -0
  167. package/images/nuclear-mcp-football.webp +0 -0
  168. package/images/the-skill-dealer.webp +0 -0
  169. package/mise.toml +65 -0
  170. package/package.json +46 -0
  171. package/scripts/docker-entrypoint.sh +29 -0
  172. package/scripts/test-echo-server.mjs +28 -0
  173. package/scripts/test-mcp-integration.mjs +59 -0
  174. package/scripts/test-tool-cli-smoke.mjs +62 -0
  175. package/src/code-mode/code-mode-integration.test.ts +135 -0
  176. package/src/code-mode/eligibility.test.ts +60 -0
  177. package/src/code-mode/eligibility.ts +16 -0
  178. package/src/code-mode/executor.test.ts +168 -0
  179. package/src/code-mode/executor.ts +163 -0
  180. package/src/code-mode/index.ts +208 -0
  181. package/src/code-mode/tools.ts +110 -0
  182. package/src/code-mode/type-hints.test.ts +187 -0
  183. package/src/code-mode/type-hints.ts +249 -0
  184. package/src/docker-e2e.test.ts +195 -0
  185. package/src/docker-e2e.ts +277 -0
  186. package/src/index.test.ts +8 -0
  187. package/src/index.ts +158 -0
  188. package/src/mcp/client-manager.test.ts +250 -0
  189. package/src/mcp/client-manager.ts +199 -0
  190. package/src/mcp/config-loader.test.ts +115 -0
  191. package/src/mcp/config-loader.ts +46 -0
  192. package/src/mcp/config.ts +40 -0
  193. package/src/mcp/index.ts +4 -0
  194. package/src/skills/discover.ts +81 -0
  195. package/src/skills/format.test.ts +70 -0
  196. package/src/skills/format.ts +42 -0
  197. package/src/skills/index.ts +9 -0
  198. package/src/skills/load-skill-tool.ts +136 -0
  199. package/src/skills/mcp-tool-proxy.ts +143 -0
  200. package/src/skills/skill-integration.test.ts +139 -0
  201. package/src/skills/skill-registry.test.ts +83 -0
  202. package/src/skills/skill-registry.ts +69 -0
  203. package/src/test-servers/weather-server.ts +171 -0
  204. package/src/test-servers/weather-stdio.ts +11 -0
  205. package/src/tool-cli/cli.ts +232 -0
  206. package/src/tool-cli/constants.ts +15 -0
  207. package/src/tool-cli/format.test.ts +33 -0
  208. package/src/tool-cli/format.ts +56 -0
  209. package/src/tool-cli/index.ts +3 -0
  210. package/src/tool-cli/rpc-client.ts +39 -0
  211. package/src/tool-cli/rpc-server.test.ts +193 -0
  212. package/src/tool-cli/rpc-server.ts +287 -0
  213. package/tsconfig.json +21 -0
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Integration smoke test: connect to the test-echo-server via stdio,
4
+ * discover tools, call the echo tool, disconnect, exit 0.
5
+ */
6
+ import { McpClientManager } from "../dist/mcp/client-manager.js";
7
+
8
+ const manager = new McpClientManager();
9
+
10
+ try {
11
+ await manager.connectAll(
12
+ {
13
+ mcpServers: {
14
+ "test-echo": {
15
+ type: "stdio",
16
+ command: "node",
17
+ args: ["scripts/test-echo-server.mjs"],
18
+ },
19
+ },
20
+ },
21
+ (msg) => console.log(msg),
22
+ );
23
+
24
+ const servers = manager.getConnectedServers();
25
+ if (servers.length !== 1) {
26
+ throw new Error(`Expected 1 server, got ${servers.length}`);
27
+ }
28
+
29
+ const tools = manager.getTools();
30
+ console.log(`Discovered ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ")}`);
31
+
32
+ const echoTool = tools.find((t) => t.name === "echo");
33
+ if (!echoTool) {
34
+ throw new Error('Expected to find "echo" tool');
35
+ }
36
+
37
+ // Call the tool via the client
38
+ const client = manager.getClient("test-echo");
39
+ if (!client) throw new Error("No client for test-echo");
40
+
41
+ const result = await client.callTool({
42
+ name: "echo",
43
+ arguments: { message: "hello from integration test" },
44
+ });
45
+
46
+ const text = result.content?.[0]?.text;
47
+ if (text !== "hello from integration test") {
48
+ throw new Error(`Unexpected echo result: ${JSON.stringify(result)}`);
49
+ }
50
+ console.log(`Echo tool returned: "${text}"`);
51
+
52
+ await manager.disconnectAll();
53
+ console.log("Integration test passed ✓");
54
+ process.exit(0);
55
+ } catch (err) {
56
+ console.error("Integration test failed:", err);
57
+ await manager.disconnectAll().catch(() => {});
58
+ process.exit(1);
59
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Smoke test: start RPC server with the weather test server,
4
+ * exercise every tool-cli command, then shut down.
5
+ */
6
+ import { McpClientManager } from "../dist/mcp/client-manager.js";
7
+ import { ToolCliRpcServer } from "../dist/tool-cli/rpc-server.js";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+ const CLI = "dist/tool-cli/cli.js";
13
+ const manager = new McpClientManager();
14
+ let rpc;
15
+
16
+ async function run(...args) {
17
+ const { stdout } = await execFileAsync("node", [CLI, ...args], {
18
+ encoding: "utf-8",
19
+ env: { ...process.env, TOOL_CLI_PORT: String(rpc.getPort()) },
20
+ });
21
+ console.log(`$ tool-cli ${args.join(" ")}`);
22
+ console.log(stdout);
23
+ return stdout;
24
+ }
25
+
26
+ try {
27
+ await manager.connectOne("weather", {
28
+ type: "stdio",
29
+ command: "node",
30
+ args: ["dist/test-servers/weather-stdio.js"],
31
+ });
32
+
33
+ rpc = new ToolCliRpcServer(manager, 0);
34
+ await rpc.start(console.log);
35
+
36
+ // --help: list servers
37
+ const help = await run("--help");
38
+ if (!help.includes("weather")) throw new Error("--help missing weather server");
39
+
40
+ // <server>: list tools
41
+ const tools = await run("weather");
42
+ if (!tools.includes("check_weather_for_city"))
43
+ throw new Error("tool list missing check_weather_for_city");
44
+
45
+ // <server> <tool>: describe
46
+ const desc = await run("weather", "check_weather_for_city");
47
+ if (!desc.includes("city")) throw new Error("describe missing city param");
48
+
49
+ // <server> <tool> <args>: call
50
+ const result = await run("weather", "check_weather_for_city", '{"city":"London"}');
51
+ if (!result.includes("London")) throw new Error("call result missing London");
52
+
53
+ await rpc.stop();
54
+ await manager.disconnectAll();
55
+ console.log("Smoke test passed ✓");
56
+ process.exit(0);
57
+ } catch (err) {
58
+ console.error("Smoke test failed:", err);
59
+ await rpc?.stop().catch(() => {});
60
+ await manager.disconnectAll().catch(() => {});
61
+ process.exit(1);
62
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { McpClientManager } from "../mcp/client-manager.js";
3
+ import { CodeModeManager } from "./index.js";
4
+
5
+ /**
6
+ * Integration test: connect to the test weather server, verify code mode
7
+ * eligibility, type hint generation, and sandboxed execution with real
8
+ * MCP tool calls returning structuredContent.
9
+ */
10
+ describe("code mode integration (weather server)", () => {
11
+ const manager = new McpClientManager();
12
+ const codeMode = new CodeModeManager();
13
+
14
+ beforeAll(async () => {
15
+ await manager.connectAll({
16
+ mcpServers: {
17
+ "test-weather": {
18
+ type: "stdio",
19
+ command: "node",
20
+ args: ["dist/test-servers/weather-stdio.js"],
21
+ },
22
+ },
23
+ });
24
+
25
+ codeMode.initialize(manager);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await manager.disconnectAll();
30
+ });
31
+
32
+ it("discovers eligible tools with readOnlyHint + outputSchema", () => {
33
+ const eligible = codeMode.getEligibleTools();
34
+ expect(eligible.length).toBeGreaterThanOrEqual(2);
35
+ const names = eligible.map((t) => t.name).sort();
36
+ expect(names).toContain("check_weather_for_city");
37
+ expect(names).toContain("check_weekly_forecast_for_city");
38
+ expect(names).toContain("echo");
39
+ });
40
+
41
+ it("generates type hints for eligible tools", () => {
42
+ const hints = codeMode.getTypeHints();
43
+ expect(hints).toContain("declare const codemode");
44
+ expect(hints).toContain("check_weather_for_city");
45
+ expect(hints).toContain("check_weekly_forecast_for_city");
46
+ expect(hints).toContain("echo");
47
+ expect(hints).toContain("listTools");
48
+ });
49
+
50
+ it("reports as active", () => {
51
+ expect(codeMode.isActive).toBe(true);
52
+ });
53
+
54
+ it("generates system prompt section", () => {
55
+ const section = codeMode.formatSystemPromptSection();
56
+ expect(section).toContain("<code_mode>");
57
+ expect(section).toContain("code_search");
58
+ expect(section).toContain("code_execute");
59
+ expect(section).toContain("declare const codemode");
60
+ });
61
+
62
+ it("executes code that calls a single tool", async () => {
63
+ const result = await codeMode.executeCode(`
64
+ const weather = await codemode.check_weather_for_city({ city: "Tokyo" });
65
+ return weather;
66
+ `);
67
+
68
+ expect(result.error).toBeUndefined();
69
+ expect(result.result).toHaveProperty("temperature", 26);
70
+ expect(result.result).toHaveProperty("conditions", "Sunny");
71
+ expect(result.result).toHaveProperty("humidity", 55);
72
+ });
73
+
74
+ it("executes code that chains multiple tool calls", async () => {
75
+ const result = await codeMode.executeCode(`
76
+ const cities = ["London", "Tokyo"];
77
+ const results = [];
78
+ for (const city of cities) {
79
+ const w = await codemode.check_weather_for_city({ city });
80
+ results.push({ city, temp: w.temperature });
81
+ }
82
+ return results;
83
+ `);
84
+
85
+ expect(result.error).toBeUndefined();
86
+ expect(result.result).toEqual([
87
+ { city: "London", temp: 14 },
88
+ { city: "Tokyo", temp: 26 },
89
+ ]);
90
+ });
91
+
92
+ it("executes code that uses listTools", async () => {
93
+ const result = await codeMode.executeCode(`
94
+ const tools = await codemode.listTools();
95
+ return tools.sort();
96
+ `);
97
+
98
+ expect(result.error).toBeUndefined();
99
+ const names = result.result as string[];
100
+ expect(names).toContain("check_weather_for_city");
101
+ expect(names).toContain("echo");
102
+ });
103
+
104
+ it("executes code that calls echo tool", async () => {
105
+ const result = await codeMode.executeCode(`
106
+ const r = await codemode.echo({ message: "hello code mode" });
107
+ return r;
108
+ `);
109
+
110
+ expect(result.error).toBeUndefined();
111
+ expect(result.result).toEqual({ echo: "hello code mode" });
112
+ });
113
+
114
+ it("handles tool errors gracefully", async () => {
115
+ const result = await codeMode.executeCode(`
116
+ const w = await codemode.check_weather_for_city({ city: "Atlantis" });
117
+ return w;
118
+ `);
119
+
120
+ // Atlantis isn't in the data — server returns text-only content (no structuredContent)
121
+ // The executor should still return something (parsed text or raw string)
122
+ expect(result.error).toBeUndefined();
123
+ expect(result.result).toBeDefined();
124
+ });
125
+
126
+ it("search mode works for tool discovery", async () => {
127
+ const result = await codeMode.searchTools(`
128
+ const tools = await codemode.listTools();
129
+ return tools;
130
+ `);
131
+
132
+ expect(result.error).toBeUndefined();
133
+ expect(Array.isArray(result.result)).toBe(true);
134
+ });
135
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { McpTool } from "../mcp/index.js";
3
+ import { isEligibleForCodeMode } from "./eligibility.js";
4
+
5
+ function makeTool(overrides: Partial<McpTool> = {}): McpTool {
6
+ return {
7
+ name: "test_tool",
8
+ description: "A test tool",
9
+ inputSchema: { type: "object", properties: {} },
10
+ serverName: "test-server",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("isEligibleForCodeMode", () => {
16
+ it("returns true when readOnlyHint is true and outputSchema is defined", () => {
17
+ const tool = makeTool({
18
+ annotations: { readOnlyHint: true },
19
+ outputSchema: { type: "object", properties: { result: { type: "string" } } },
20
+ });
21
+ expect(isEligibleForCodeMode(tool)).toBe(true);
22
+ });
23
+
24
+ it("returns false when readOnlyHint is false", () => {
25
+ const tool = makeTool({
26
+ annotations: { readOnlyHint: false },
27
+ outputSchema: { type: "object", properties: {} },
28
+ });
29
+ expect(isEligibleForCodeMode(tool)).toBe(false);
30
+ });
31
+
32
+ it("returns false when readOnlyHint is missing", () => {
33
+ const tool = makeTool({
34
+ annotations: {},
35
+ outputSchema: { type: "object", properties: {} },
36
+ });
37
+ expect(isEligibleForCodeMode(tool)).toBe(false);
38
+ });
39
+
40
+ it("returns false when annotations is undefined", () => {
41
+ const tool = makeTool({
42
+ annotations: undefined,
43
+ outputSchema: { type: "object", properties: {} },
44
+ });
45
+ expect(isEligibleForCodeMode(tool)).toBe(false);
46
+ });
47
+
48
+ it("returns false when outputSchema is missing", () => {
49
+ const tool = makeTool({
50
+ annotations: { readOnlyHint: true },
51
+ outputSchema: undefined,
52
+ });
53
+ expect(isEligibleForCodeMode(tool)).toBe(false);
54
+ });
55
+
56
+ it("returns false when both readOnlyHint and outputSchema are missing", () => {
57
+ const tool = makeTool();
58
+ expect(isEligibleForCodeMode(tool)).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,16 @@
1
+ import type { McpClientManager, McpTool } from "../mcp/index.js";
2
+
3
+ /**
4
+ * A tool is eligible for code mode when:
5
+ * 1. annotations.readOnlyHint === true — does not modify its environment
6
+ * 2. outputSchema is defined — results are typed and parseable
7
+ */
8
+ export function isEligibleForCodeMode(tool: McpTool): boolean {
9
+ const annotations = tool.annotations as { readOnlyHint?: boolean } | undefined;
10
+ return annotations?.readOnlyHint === true && tool.outputSchema != null;
11
+ }
12
+
13
+ /** Get all tools eligible for code mode across all connected MCP servers. */
14
+ export function getEligibleTools(mcpManager: McpClientManager): McpTool[] {
15
+ return mcpManager.getTools().filter(isEligibleForCodeMode);
16
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { executeInSandbox, normalizeCode } from "./executor.js";
3
+
4
+ describe("normalizeCode", () => {
5
+ it("strips markdown code fences", () => {
6
+ expect(normalizeCode("```js\nconst x = 1;\n```")).toBe("const x = 1;");
7
+ });
8
+
9
+ it("strips typescript fences", () => {
10
+ expect(normalizeCode("```typescript\nconst x = 1;\n```")).toBe("const x = 1;");
11
+ });
12
+
13
+ it("wraps arrow function in invocation", () => {
14
+ expect(normalizeCode("async () => { return 42; }")).toBe(
15
+ "return (async () => { return 42; })();",
16
+ );
17
+ });
18
+
19
+ it("wraps function declaration in invocation", () => {
20
+ const code = "async function main() { return 42; }";
21
+ const result = normalizeCode(code);
22
+ expect(result).toContain("async function main()");
23
+ expect(result).toContain("return main();");
24
+ });
25
+
26
+ it("strips export default", () => {
27
+ const result = normalizeCode("export default async () => { return 42; }");
28
+ expect(result).toContain("return (async () => { return 42; })();");
29
+ });
30
+
31
+ it("leaves bare code as-is", () => {
32
+ expect(normalizeCode("const x = 1;\nreturn x;")).toBe("const x = 1;\nreturn x;");
33
+ });
34
+ });
35
+
36
+ describe("executeInSandbox", () => {
37
+ it("executes simple code and returns result", async () => {
38
+ const result = await executeInSandbox("return 2 + 2;", [], async () => ({}), {
39
+ timeoutMs: 5000,
40
+ });
41
+
42
+ expect(result.result).toBe(4);
43
+ expect(result.error).toBeUndefined();
44
+ });
45
+
46
+ it("captures console.log output", async () => {
47
+ const result = await executeInSandbox(
48
+ 'console.log("hello", "world"); return 1;',
49
+ [],
50
+ async () => ({}),
51
+ { timeoutMs: 5000 },
52
+ );
53
+
54
+ expect(result.result).toBe(1);
55
+ expect(result.logs).toContain("hello world");
56
+ });
57
+
58
+ it("dispatches tool calls through the callback", async () => {
59
+ const dispatched: { name: string; args: Record<string, unknown> }[] = [];
60
+
61
+ const dispatch = async (name: string, args: Record<string, unknown>) => {
62
+ dispatched.push({ name, args });
63
+ return { results: ["doc1", "doc2"] };
64
+ };
65
+
66
+ const code = `
67
+ const result = await codemode.search_docs({ query: "test" });
68
+ return result;
69
+ `;
70
+
71
+ const result = await executeInSandbox(code, ["search_docs"], dispatch, { timeoutMs: 5000 });
72
+
73
+ expect(result.error).toBeUndefined();
74
+ expect(result.result).toEqual({ results: ["doc1", "doc2"] });
75
+ expect(dispatched).toHaveLength(1);
76
+ expect(dispatched[0].name).toBe("search_docs");
77
+ expect(dispatched[0].args).toEqual({ query: "test" });
78
+ });
79
+
80
+ it("handles tools with special characters in names", async () => {
81
+ const dispatched: string[] = [];
82
+
83
+ const dispatch = async (name: string) => {
84
+ dispatched.push(name);
85
+ return { ok: true };
86
+ };
87
+
88
+ const code = `
89
+ const result = await codemode.github_list_repos({});
90
+ return result;
91
+ `;
92
+
93
+ const result = await executeInSandbox(code, ["github.list-repos"], dispatch, {
94
+ timeoutMs: 5000,
95
+ });
96
+
97
+ expect(result.error).toBeUndefined();
98
+ expect(dispatched).toContain("github.list-repos");
99
+ });
100
+
101
+ it("returns error for invalid code", async () => {
102
+ const result = await executeInSandbox("throw new Error('boom');", [], async () => ({}), {
103
+ timeoutMs: 5000,
104
+ });
105
+
106
+ expect(result.error).toContain("boom");
107
+ expect(result.result).toBeUndefined();
108
+ });
109
+
110
+ it("enforces timeout", async () => {
111
+ const result = await executeInSandbox("while(true) {}", [], async () => ({}), {
112
+ timeoutMs: 100,
113
+ });
114
+
115
+ expect(result.error).toBeDefined();
116
+ expect(result.result).toBeUndefined();
117
+ });
118
+
119
+ it("chains multiple tool calls", async () => {
120
+ const calls: string[] = [];
121
+
122
+ const dispatch = async (name: string, args: Record<string, unknown>) => {
123
+ calls.push(name);
124
+ if (name === "list_items") return { items: ["a", "b", "c"] };
125
+ if (name === "get_details") return { detail: `info for ${args.id}` };
126
+ return {};
127
+ };
128
+
129
+ const code = `
130
+ const { items } = await codemode.list_items({});
131
+ const details = [];
132
+ for (const id of items) {
133
+ const d = await codemode.get_details({ id });
134
+ details.push(d.detail);
135
+ }
136
+ return details;
137
+ `;
138
+
139
+ const result = await executeInSandbox(code, ["list_items", "get_details"], dispatch, {
140
+ timeoutMs: 5000,
141
+ });
142
+
143
+ expect(result.error).toBeUndefined();
144
+ expect(result.result).toEqual(["info for a", "info for b", "info for c"]);
145
+ expect(calls).toEqual(["list_items", "get_details", "get_details", "get_details"]);
146
+ });
147
+
148
+ it("prevents imports", async () => {
149
+ const code = 'import("fs")';
150
+ const result = await executeInSandbox(code, [], async () => ({}), { timeoutMs: 5000 });
151
+
152
+ expect(result.error).toBeDefined();
153
+ });
154
+
155
+ it("listTools returns available tools", async () => {
156
+ const code = `
157
+ const tools = await codemode.listTools();
158
+ return tools;
159
+ `;
160
+
161
+ const result = await executeInSandbox(code, ["search", "execute"], async () => ({}), {
162
+ timeoutMs: 5000,
163
+ });
164
+
165
+ expect(result.error).toBeUndefined();
166
+ expect(result.result).toEqual(["search", "execute"]);
167
+ });
168
+ });
@@ -0,0 +1,163 @@
1
+ import ivm from "isolated-vm";
2
+ import { sanitizeToolName } from "./type-hints.js";
3
+
4
+ /** Result of code execution. */
5
+ export interface ExecuteResult {
6
+ result: unknown;
7
+ error?: string;
8
+ logs: string[];
9
+ }
10
+
11
+ /** A function the sandbox can call to invoke an MCP tool. */
12
+ export type ToolDispatchFn = (toolName: string, args: Record<string, unknown>) => Promise<unknown>;
13
+
14
+ export interface ExecutorOptions {
15
+ /** Memory limit in MB for the V8 isolate. Default: 128. */
16
+ memoryLimit?: number;
17
+ /** Execution timeout in milliseconds. Default: 30000. */
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ const DEFAULT_MEMORY_LIMIT = 128;
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+
24
+ /**
25
+ * Execute model-generated JavaScript code in an isolated V8 sandbox.
26
+ *
27
+ * The code runs in a fresh `isolated-vm` isolate with:
28
+ * - Enforced memory limit (default 128MB)
29
+ * - Execution timeout (default 30s)
30
+ * - No access to Node.js APIs, filesystem, or network
31
+ * - Only access to provided tool dispatch functions via `codemode.*` proxy
32
+ *
33
+ * Tool calls are dispatched to the host via `Reference` callbacks —
34
+ * actual MCP tool execution happens outside the sandbox.
35
+ */
36
+ export async function executeInSandbox(
37
+ code: string,
38
+ toolNames: string[],
39
+ dispatch: ToolDispatchFn,
40
+ options: ExecutorOptions = {},
41
+ ): Promise<ExecuteResult> {
42
+ const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
43
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44
+
45
+ const isolate = new ivm.Isolate({ memoryLimit });
46
+ try {
47
+ return await runInIsolate(isolate, code, toolNames, dispatch, timeoutMs);
48
+ } finally {
49
+ isolate.dispose();
50
+ }
51
+ }
52
+
53
+ async function runInIsolate(
54
+ isolate: ivm.Isolate,
55
+ code: string,
56
+ toolNames: string[],
57
+ dispatch: ToolDispatchFn,
58
+ timeoutMs: number,
59
+ ): Promise<ExecuteResult> {
60
+ const ctx = await isolate.createContext();
61
+ const jail = ctx.global;
62
+
63
+ const logs: string[] = [];
64
+
65
+ // Inject console.log that captures to logs array
66
+ const logCallback = new ivm.Callback((...args: unknown[]) => {
67
+ logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
68
+ });
69
+ await jail.set("__log", logCallback);
70
+
71
+ // Inject tool dispatcher Reference (async callback)
72
+ const dispatchRef = new ivm.Reference(async (toolName: string, argsJson: string) => {
73
+ const args = JSON.parse(argsJson) as Record<string, unknown>;
74
+ const result = await dispatch(toolName, args);
75
+ return JSON.stringify(result === undefined ? null : result);
76
+ });
77
+ await jail.set("__dispatch", dispatchRef);
78
+
79
+ // Build the tool call helper and codemode proxy as setup code
80
+ const toolProxyEntries = toolNames
81
+ .map((name) => {
82
+ const safe = sanitizeToolName(name);
83
+ return ` ${safe}: async (args) => __callTool(${JSON.stringify(name)}, args)`;
84
+ })
85
+ .join(",\n");
86
+
87
+ const normalized = normalizeCode(code);
88
+
89
+ // Use ctx.eval with { promise: true } instead of compileModule,
90
+ // because module.evaluate() can resolve prematurely with multiple
91
+ // sequential async Reference.apply calls.
92
+ const wrappedCode = `
93
+ (async () => {
94
+ const console = { log: (...args) => __log(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')) };
95
+ const __callTool = async (name, args) => {
96
+ const r = await __dispatch.apply(undefined, [name, JSON.stringify(args ?? {})], { arguments: { copy: true }, result: { promise: true, copy: true } });
97
+ return JSON.parse(r);
98
+ };
99
+ const codemode = {
100
+ listTools: async () => ${JSON.stringify(toolNames)},
101
+ describeTools: async (names) => "Use the typed codemode.toolName(args) methods instead.",
102
+ ${toolProxyEntries}
103
+ };
104
+
105
+ const __userFn = async () => {
106
+ ${normalized}
107
+ };
108
+
109
+ return JSON.stringify({ value: await __userFn() });
110
+ })()
111
+ `;
112
+
113
+ try {
114
+ const rawResult = (await ctx.eval(wrappedCode, {
115
+ promise: true,
116
+ copy: true,
117
+ timeout: timeoutMs,
118
+ })) as string;
119
+
120
+ const parsed = JSON.parse(rawResult) as { value: unknown };
121
+ return { result: parsed.value, logs };
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ return { result: undefined, error: message, logs };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Normalize model-generated code:
130
+ * - Strip markdown code fences
131
+ * - Handle arrow functions, function declarations, export default
132
+ * - Otherwise leave code as a statement block; callers must explicitly `return` a value
133
+ */
134
+ export function normalizeCode(code: string): string {
135
+ let normalized = code.trim();
136
+
137
+ // Strip markdown code fences
138
+ normalized = normalized.replace(/^```(?:js|javascript|typescript|ts)?\s*\n?/i, "");
139
+ normalized = normalized.replace(/\n?```\s*$/i, "");
140
+ normalized = normalized.trim();
141
+
142
+ // If it's an arrow function or function expression, invoke it
143
+ if (/^(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/.test(normalized)) {
144
+ return `return (${normalized})();`;
145
+ }
146
+
147
+ // If it's a function declaration, invoke it
148
+ if (/^(?:async\s+)?function\s+/.test(normalized)) {
149
+ const match = normalized.match(/^(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
150
+ if (match) {
151
+ return `${normalized}\nreturn ${match[1]}();`;
152
+ }
153
+ }
154
+
155
+ // If it starts with export default, strip the export default
156
+ if (/^export\s+default\s+/.test(normalized)) {
157
+ normalized = normalized.replace(/^export\s+default\s+/, "");
158
+ return `return (${normalized})();`;
159
+ }
160
+
161
+ // Otherwise, treat as a code block — return the last expression
162
+ return normalized;
163
+ }