@teampitch/mcpx 0.2.1

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.
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import type { Backend } from "./backends.js";
4
+ import { executeCode, sanitizeName } from "./executor.js";
5
+
6
+ const emptyBackends = new Map<string, Backend>();
7
+
8
+ describe("executeCode", () => {
9
+ test("returns simple value with no tool calls", async () => {
10
+ const result = await executeCode("return 42;", emptyBackends);
11
+ expect(result.isOk()).toBe(true);
12
+ if (result.isOk()) expect(result.value.value).toBe(42);
13
+ });
14
+
15
+ test("returns string value", async () => {
16
+ const result = await executeCode('return "hello world";', emptyBackends);
17
+ expect(result.isOk()).toBe(true);
18
+ if (result.isOk()) expect(result.value.value).toBe("hello world");
19
+ });
20
+
21
+ test("returns object value", async () => {
22
+ const result = await executeCode('return { key: "value", num: 1 };', emptyBackends);
23
+ expect(result.isOk()).toBe(true);
24
+ if (result.isOk()) expect(result.value.value).toEqual({ key: "value", num: 1 });
25
+ });
26
+
27
+ test("returns array value", async () => {
28
+ const result = await executeCode("return [1, 2, 3];", emptyBackends);
29
+ expect(result.isOk()).toBe(true);
30
+ if (result.isOk()) expect(result.value.value).toEqual([1, 2, 3]);
31
+ });
32
+
33
+ test("handles async computation", async () => {
34
+ const result = await executeCode(
35
+ "const x = await Promise.resolve(10); return x * 2;",
36
+ emptyBackends,
37
+ );
38
+ expect(result.isOk()).toBe(true);
39
+ if (result.isOk()) expect(result.value.value).toBe(20);
40
+ });
41
+
42
+ test("returns null for code with no return", async () => {
43
+ const result = await executeCode("const x = 1;", emptyBackends);
44
+ expect(result.isOk()).toBe(true);
45
+ if (result.isOk()) expect(result.value.value == null).toBe(true);
46
+ });
47
+
48
+ test("handles syntax error gracefully", async () => {
49
+ const result = await executeCode("{{{{invalid syntax", emptyBackends);
50
+ expect(result.isErr()).toBe(true);
51
+ });
52
+
53
+ test("handles runtime error gracefully", async () => {
54
+ const result = await executeCode('throw new Error("something went wrong");', emptyBackends);
55
+ expect(result.isErr()).toBe(true);
56
+ });
57
+
58
+ test("with empty backends, no tool functions are injected", async () => {
59
+ const result = await executeCode(
60
+ "return typeof nonexistent_tool === 'undefined' ? 'no-tools' : 'has-tools';",
61
+ emptyBackends,
62
+ );
63
+ expect(result.isOk()).toBe(true);
64
+ if (result.isOk()) expect(result.value.value).toBe("no-tools");
65
+ });
66
+
67
+ test("tool bindings return real values via await", async () => {
68
+ const mockBackend: Backend = {
69
+ name: "test",
70
+ client: {
71
+ callTool: async ({
72
+ name,
73
+ arguments: args,
74
+ }: {
75
+ name: string;
76
+ arguments?: Record<string, unknown>;
77
+ }) => ({
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: `called ${name} with ${JSON.stringify(args)}`,
82
+ },
83
+ ],
84
+ }),
85
+ close: async () => {},
86
+ } as any,
87
+ tools: [{ name: "echo", description: "echo tool", inputSchema: {} }],
88
+ };
89
+ const backends = new Map([["test", mockBackend]]);
90
+
91
+ const result = await executeCode(
92
+ 'const r = await test_echo({ msg: "hello" }); return r.content[0].text;',
93
+ backends,
94
+ );
95
+ expect(result.isOk()).toBe(true);
96
+ if (result.isOk()) expect(result.value.value).toBe('called echo with {"msg":"hello"}');
97
+ });
98
+
99
+ test("multi-step tool calls use results from previous calls", async () => {
100
+ let callCount = 0;
101
+ const mockBackend: Backend = {
102
+ name: "mock",
103
+ client: {
104
+ callTool: async ({
105
+ name,
106
+ arguments: args,
107
+ }: {
108
+ name: string;
109
+ arguments?: Record<string, unknown>;
110
+ }) => {
111
+ callCount++;
112
+ if (name === "get_id") return { content: [{ type: "text", text: "42" }] };
113
+ if (name === "get_details")
114
+ return {
115
+ content: [{ type: "text", text: `details for ${(args as any).id}` }],
116
+ };
117
+ return { content: [{ type: "text", text: "unknown" }] };
118
+ },
119
+ close: async () => {},
120
+ } as any,
121
+ tools: [
122
+ { name: "get_id", description: "get id", inputSchema: {} },
123
+ { name: "get_details", description: "get details", inputSchema: {} },
124
+ ],
125
+ };
126
+ const backends = new Map([["mock", mockBackend]]);
127
+
128
+ const result = await executeCode(
129
+ `const idResult = await mock_get_id({});
130
+ const id = idResult.content[0].text;
131
+ const details = await mock_get_details({ id });
132
+ return details.content[0].text;`,
133
+ backends,
134
+ );
135
+ expect(result.isOk()).toBe(true);
136
+ if (result.isOk()) expect(result.value.value).toBe("details for 42");
137
+ expect(callCount).toBe(2);
138
+ });
139
+
140
+ test("unknown tool returns error object", async () => {
141
+ const result = await executeCode(
142
+ "const r = await nonexistent_tool_name({}); return r;",
143
+ emptyBackends,
144
+ );
145
+ // Should either error or return undefined since tool doesn't exist
146
+ expect(result.isOk() || result.isErr()).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe("sanitizeName", () => {
151
+ test("replaces hyphens", () => expect(sanitizeName("my-tool")).toBe("my_tool"));
152
+ test("replaces dots", () => expect(sanitizeName("my.tool")).toBe("my_tool"));
153
+ test("keeps underscores", () => expect(sanitizeName("my_tool")).toBe("my_tool"));
154
+ test("keeps alphanumeric", () => expect(sanitizeName("tool123")).toBe("tool123"));
155
+ });
@@ -0,0 +1,195 @@
1
+ import { ok, err, type Result } from "neverthrow";
2
+ import { createNodeDriver, NodeExecutionDriver, type BindingFunction } from "secure-exec";
3
+
4
+ import { validateSyntax } from "./ast.js";
5
+ import type { Backend } from "./backends.js";
6
+
7
+ type ToolFunction = (args: Record<string, unknown>) => Promise<unknown>;
8
+
9
+ export interface LogEntry {
10
+ level: "log" | "warn" | "error" | "info" | "debug";
11
+ args: unknown[];
12
+ }
13
+
14
+ export interface ExecuteResult {
15
+ value: unknown;
16
+ logs: LogEntry[];
17
+ }
18
+
19
+ export type ExecuteError =
20
+ | { kind: "runtime"; code: number }
21
+ | {
22
+ kind: "parse";
23
+ message: string;
24
+ line?: number;
25
+ column?: number;
26
+ snippet?: string;
27
+ }
28
+ | { kind: "exception"; message: string };
29
+
30
+ /** Convert snake_case to camelCase */
31
+ export function snakeToCamel(name: string): string {
32
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
33
+ }
34
+
35
+ /** Build a map of prefixed tool names → backend tool call functions */
36
+ function buildToolRegistry(backends: Map<string, Backend>): Map<string, ToolFunction> {
37
+ const registry = new Map<string, ToolFunction>();
38
+
39
+ for (const [name, backend] of backends) {
40
+ for (const tool of backend.tools) {
41
+ const prefixed = `${name}_${sanitizeName(tool.name)}`;
42
+ registry.set(prefixed, async (args) => {
43
+ return backend.client.callTool({
44
+ name: tool.name,
45
+ arguments: args,
46
+ });
47
+ });
48
+ }
49
+ }
50
+
51
+ return registry;
52
+ }
53
+
54
+ /** Build a map of backend name → tool names (for namespace generation) */
55
+ function buildBackendToolMap(backends: Map<string, Backend>): Map<string, string[]> {
56
+ const map = new Map<string, string[]>();
57
+ for (const [name, backend] of backends) {
58
+ map.set(
59
+ name,
60
+ backend.tools.map((t) => t.name),
61
+ );
62
+ }
63
+ return map;
64
+ }
65
+
66
+ /**
67
+ * Generate code bindings: flat aliases + namespace proxies + console capture.
68
+ */
69
+ function wrapCodeWithBindings(
70
+ code: string,
71
+ toolNames: string[],
72
+ backendTools: Map<string, string[]>,
73
+ ): string {
74
+ // Console capture
75
+ const consoleOverride = `
76
+ const __consoleLogs = [];
77
+ const console = {
78
+ log: (...args) => __consoleLogs.push({ level: "log", args }),
79
+ warn: (...args) => __consoleLogs.push({ level: "warn", args }),
80
+ error: (...args) => __consoleLogs.push({ level: "error", args }),
81
+ info: (...args) => __consoleLogs.push({ level: "info", args }),
82
+ debug: (...args) => __consoleLogs.push({ level: "debug", args }),
83
+ };`;
84
+
85
+ // Flat aliases (backward compat)
86
+ const aliases = toolNames
87
+ .map((n) => `const ${n} = (args) => SecureExec.bindings.callTool("${n}", args || {});`)
88
+ .join("\n");
89
+
90
+ // Namespace proxies
91
+ const namespaces: string[] = [];
92
+ for (const [backendName, tools] of backendTools) {
93
+ const methods = tools
94
+ .map((toolName) => {
95
+ const camelName = snakeToCamel(sanitizeName(toolName));
96
+ const prefixed = `${backendName}_${sanitizeName(toolName)}`;
97
+ return ` ${camelName}: (args) => SecureExec.bindings.callTool("${prefixed}", args || {})`;
98
+ })
99
+ .join(",\n");
100
+ namespaces.push(`const ${backendName} = {\n${methods}\n};`);
101
+ }
102
+
103
+ return `
104
+ ${consoleOverride}
105
+ ${aliases}
106
+ ${namespaces.join("\n")}
107
+
108
+ const __userResult = await (async () => { ${code} })();
109
+ export default { value: __userResult, logs: __consoleLogs };
110
+ `;
111
+ }
112
+
113
+ /** Execute LLM-generated code in a V8 isolate with access to backend MCP tools */
114
+ export async function executeCode(
115
+ code: string,
116
+ backends: Map<string, Backend>,
117
+ opts?: { memoryLimit?: number; cpuTimeLimitMs?: number },
118
+ ): Promise<Result<ExecuteResult, ExecuteError>> {
119
+ const registry = buildToolRegistry(backends);
120
+ const backendTools = buildBackendToolMap(backends);
121
+
122
+ const callTool: BindingFunction = async (name: unknown, args: unknown) => {
123
+ const toolName = name as string;
124
+ const toolArgs = (args as Record<string, unknown>) ?? {};
125
+ const fn = registry.get(toolName);
126
+ if (!fn) {
127
+ return { error: `Unknown tool: ${toolName}` };
128
+ }
129
+ try {
130
+ return await fn(toolArgs);
131
+ } catch (e) {
132
+ return { error: (e as Error).message };
133
+ }
134
+ };
135
+
136
+ const wrappedCode = wrapCodeWithBindings(code, [...registry.keys()], backendTools);
137
+
138
+ // AST validation — catch syntax errors with better messages before V8 execution
139
+ const syntaxError = validateSyntax(wrappedCode);
140
+ if (syntaxError) {
141
+ return err({
142
+ kind: "parse",
143
+ message: syntaxError.message,
144
+ line: syntaxError.line,
145
+ column: syntaxError.column,
146
+ snippet: syntaxError.snippet,
147
+ });
148
+ }
149
+
150
+ const systemDriver = createNodeDriver({
151
+ permissions: {
152
+ fs: () => ({ allow: false }),
153
+ network: () => ({ allow: false }),
154
+ childProcess: () => ({ allow: false }),
155
+ env: () => ({ allow: false }),
156
+ },
157
+ });
158
+
159
+ const driver = new NodeExecutionDriver({
160
+ system: systemDriver,
161
+ runtime: {
162
+ process: { cwd: "/root", env: {} },
163
+ os: { homedir: "/root", tmpdir: "/tmp" },
164
+ },
165
+ memoryLimit: opts?.memoryLimit ?? 64,
166
+ cpuTimeLimitMs: opts?.cpuTimeLimitMs ?? 30_000,
167
+ bindings: { callTool },
168
+ });
169
+
170
+ try {
171
+ const runResult = await driver.run(wrappedCode, "/entry.mjs");
172
+
173
+ if (runResult.code !== 0) {
174
+ return err({ kind: "runtime", code: runResult.code });
175
+ }
176
+
177
+ const exports = runResult.exports as Record<string, unknown>;
178
+ const result = exports?.default as {
179
+ value: unknown;
180
+ logs: LogEntry[];
181
+ } | null;
182
+ return ok({
183
+ value: result?.value ?? null,
184
+ logs: result?.logs ?? [],
185
+ });
186
+ } catch (e) {
187
+ return err({ kind: "exception", message: (e as Error).message });
188
+ } finally {
189
+ driver.dispose();
190
+ }
191
+ }
192
+
193
+ export function sanitizeName(name: string): string {
194
+ return name.replace(/[^a-zA-Z0-9_]/g, "_");
195
+ }