@teddysc/mcp-codemode 1.0.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.
- package/.claude/settings.local.json +14 -0
- package/CLAUDE.md +115 -0
- package/Makefile +15 -0
- package/bun.lock +206 -0
- package/mcp-codemode.toml.example +33 -0
- package/package.json +21 -0
- package/src/cli.ts +249 -0
- package/src/config.ts +236 -0
- package/src/executor.ts +116 -0
- package/src/server.ts +550 -0
- package/src/types.ts +216 -0
- package/tsconfig.json +14 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* mcp-codemode CLI
|
|
4
|
+
*
|
|
5
|
+
* Wraps one or more stdio/HTTP MCP servers with two codemode tools:
|
|
6
|
+
* search – LLM writes JS to inspect upstream tool schemas
|
|
7
|
+
* execute – LLM writes JS to orchestrate upstream tools
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* mcp-codemode [options] -- <cmd> [args...]
|
|
11
|
+
* mcp-codemode [options] <url>
|
|
12
|
+
* mcp-codemode [options] --config ./mcp-codemode.toml
|
|
13
|
+
*
|
|
14
|
+
* Examples:
|
|
15
|
+
* mcp-codemode -- bunx @playwright/mcp@latest --extension
|
|
16
|
+
* mcp-codemode --no-add-search -- npx @modelcontextprotocol/server-filesystem /tmp
|
|
17
|
+
* mcp-codemode https://mcp.example.com/sse
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import cac from "cac";
|
|
21
|
+
import { loadTomlConfig } from "./config.js";
|
|
22
|
+
import { runServer, type UpstreamConfig } from "./server.js";
|
|
23
|
+
|
|
24
|
+
const cli = cac("mcp-codemode");
|
|
25
|
+
|
|
26
|
+
cli
|
|
27
|
+
.option("--add-search", "Always expose the search tool (default: auto when >= 10 upstream tools)")
|
|
28
|
+
.option("--timeout <ms>", "Execution timeout in milliseconds")
|
|
29
|
+
.option("--name <name>", "MCP server name")
|
|
30
|
+
.option("--header <name:value>", "HTTP header for URL upstream mode (repeatable)")
|
|
31
|
+
.option(
|
|
32
|
+
"--execute-tool-list",
|
|
33
|
+
"Include available tools in execute description (use --no-execute-tool-list to hide)",
|
|
34
|
+
)
|
|
35
|
+
.option(
|
|
36
|
+
"--config <path>",
|
|
37
|
+
"Path to a TOML config file with one or more upstream servers",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
cli.help();
|
|
41
|
+
cli.version("1.0.0");
|
|
42
|
+
|
|
43
|
+
const { options, args: positional } = cli.parse();
|
|
44
|
+
|
|
45
|
+
if (options.help || options.version) {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --no-add-search sets options.addSearch = false via cac's built-in negation.
|
|
50
|
+
// --add-search sets options.addSearch = true.
|
|
51
|
+
// Neither flag leaves options.addSearch = undefined → auto-detect.
|
|
52
|
+
const addSearch: boolean | undefined = options.addSearch;
|
|
53
|
+
const executeToolList: boolean | undefined = options.executeToolList;
|
|
54
|
+
const configPath: string | undefined = options.config;
|
|
55
|
+
const headerOption: unknown = options.header;
|
|
56
|
+
|
|
57
|
+
// Everything after -- lands in options['--']; positional args are a fallback
|
|
58
|
+
// for the URL-only form (mcp-codemode <url>).
|
|
59
|
+
const rest: string[] = (options["--"] as string[] | undefined) ?? [];
|
|
60
|
+
const upstreamArgs = rest.length > 0 ? rest : positional;
|
|
61
|
+
|
|
62
|
+
const KNOWN_OPTION_KEYS = new Set([
|
|
63
|
+
"--",
|
|
64
|
+
"help",
|
|
65
|
+
"version",
|
|
66
|
+
"addSearch",
|
|
67
|
+
"timeout",
|
|
68
|
+
"name",
|
|
69
|
+
"header",
|
|
70
|
+
"executeToolList",
|
|
71
|
+
"config",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function camelToKebab(input: string): string {
|
|
75
|
+
return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function unknownOptionArgs(
|
|
79
|
+
parsedOptions: Record<string, unknown>,
|
|
80
|
+
): string[] {
|
|
81
|
+
const forwarded: string[] = [];
|
|
82
|
+
for (const [key, value] of Object.entries(parsedOptions)) {
|
|
83
|
+
if (KNOWN_OPTION_KEYS.has(key)) continue;
|
|
84
|
+
const flag = `--${camelToKebab(key)}`;
|
|
85
|
+
if (typeof value === "boolean") {
|
|
86
|
+
if (value) forwarded.push(flag);
|
|
87
|
+
else forwarded.push(`--no-${camelToKebab(key)}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
91
|
+
forwarded.push(flag, String(value));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
for (const item of value) {
|
|
96
|
+
forwarded.push(flag, String(item));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return forwarded;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function debugUpstreamSpawn(command: string, args: string[]): void {
|
|
104
|
+
if (!process.env.MCP_CODEMODE_DEBUG_UPSTREAM) return;
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`[mcp-codemode] upstream spawn: ${JSON.stringify({ command, args })}\n`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (configPath && upstreamArgs.length > 0) {
|
|
111
|
+
console.error(
|
|
112
|
+
"mcp-codemode: Cannot combine --config with positional/-- upstream args.\n" +
|
|
113
|
+
"Use one mode:\n" +
|
|
114
|
+
" config: mcp-codemode [options] --config ./mcp-codemode.toml\n" +
|
|
115
|
+
" stdio: mcp-codemode [options] -- <cmd> [args...]\n" +
|
|
116
|
+
" http: mcp-codemode [options] <url>",
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isHttpUrl(value: string): boolean {
|
|
122
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseTimeoutMs(value: unknown, field: string): number | undefined {
|
|
126
|
+
if (value === undefined) return undefined;
|
|
127
|
+
const parsed = Number(value);
|
|
128
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
129
|
+
throw new Error(`${field} must be a number > 0.`);
|
|
130
|
+
}
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function expandEnvPlaceholders(value: string, field: string): string {
|
|
135
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => {
|
|
136
|
+
const envValue = process.env[name];
|
|
137
|
+
if (envValue === undefined) {
|
|
138
|
+
throw new Error(`${field} references \${${name}} but ${name} is not set.`);
|
|
139
|
+
}
|
|
140
|
+
return envValue;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseHeader(raw: string): [name: string, value: string] {
|
|
145
|
+
const separator = raw.indexOf(":");
|
|
146
|
+
if (separator <= 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`--header must use "Name: Value" format. Received: ${JSON.stringify(raw)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const name = raw.slice(0, separator).trim();
|
|
152
|
+
const value = raw.slice(separator + 1).trim();
|
|
153
|
+
if (!name) {
|
|
154
|
+
throw new Error(`--header contains an empty header name.`);
|
|
155
|
+
}
|
|
156
|
+
return [name, expandEnvPlaceholders(value, `--header:${name}`)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseCliHeaders(value: unknown): Record<string, string> | undefined {
|
|
160
|
+
if (value === undefined) return undefined;
|
|
161
|
+
const values = Array.isArray(value) ? value : [value];
|
|
162
|
+
const headers: Record<string, string> = {};
|
|
163
|
+
|
|
164
|
+
for (const entry of values) {
|
|
165
|
+
if (typeof entry !== "string") {
|
|
166
|
+
throw new Error(`--header values must be strings.`);
|
|
167
|
+
}
|
|
168
|
+
const [name, parsedValue] = parseHeader(entry);
|
|
169
|
+
headers[name] = parsedValue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return headers;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cliHttpHeaders = parseCliHeaders(headerOption);
|
|
176
|
+
|
|
177
|
+
if (configPath && cliHttpHeaders) {
|
|
178
|
+
console.error(
|
|
179
|
+
"mcp-codemode: --header is only supported in URL upstream mode.\n" +
|
|
180
|
+
"Use per-server `headers` in your TOML file when using --config.",
|
|
181
|
+
);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let upstreams: UpstreamConfig[] = [];
|
|
186
|
+
let timeoutMs: number | undefined;
|
|
187
|
+
let serverName: string | undefined;
|
|
188
|
+
let effectiveAddSearch: boolean | undefined = addSearch;
|
|
189
|
+
let effectiveExecuteToolList = executeToolList ?? true;
|
|
190
|
+
|
|
191
|
+
if (configPath) {
|
|
192
|
+
const fileConfig = await loadTomlConfig(String(configPath));
|
|
193
|
+
upstreams = fileConfig.upstreams;
|
|
194
|
+
timeoutMs =
|
|
195
|
+
parseTimeoutMs(options.timeout, "--timeout") ?? fileConfig.timeoutMs;
|
|
196
|
+
serverName = (options.name as string | undefined) ?? fileConfig.serverName;
|
|
197
|
+
effectiveAddSearch = addSearch ?? fileConfig.addSearch;
|
|
198
|
+
effectiveExecuteToolList =
|
|
199
|
+
executeToolList ?? fileConfig.includeExecuteToolList ?? true;
|
|
200
|
+
if (process.env.MCP_CODEMODE_DEBUG_UPSTREAM) {
|
|
201
|
+
for (const upstream of upstreams) {
|
|
202
|
+
if (upstream.type === "http") debugUpstreamSpawn(upstream.url, []);
|
|
203
|
+
else debugUpstreamSpawn(upstream.command, upstream.args);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!configPath) {
|
|
209
|
+
if (upstreamArgs.length === 0) {
|
|
210
|
+
console.error(
|
|
211
|
+
"mcp-codemode: No upstream MCP server specified.\n" +
|
|
212
|
+
" config: mcp-codemode [options] --config ./mcp-codemode.toml\n" +
|
|
213
|
+
" stdio: mcp-codemode [options] -- <cmd> [args...]\n" +
|
|
214
|
+
" http: mcp-codemode [options] <url>",
|
|
215
|
+
);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const [command, ...cmdArgs] = upstreamArgs;
|
|
220
|
+
const recoveredUnknownArgs =
|
|
221
|
+
rest.length === 0 ? unknownOptionArgs(options as Record<string, unknown>) : [];
|
|
222
|
+
const mergedCmdArgs = [...cmdArgs, ...recoveredUnknownArgs];
|
|
223
|
+
if (isHttpUrl(command)) {
|
|
224
|
+
upstreams = [
|
|
225
|
+
{ id: "upstream", type: "http", url: command, headers: cliHttpHeaders },
|
|
226
|
+
];
|
|
227
|
+
debugUpstreamSpawn(command, []);
|
|
228
|
+
} else {
|
|
229
|
+
if (cliHttpHeaders) {
|
|
230
|
+
console.error(
|
|
231
|
+
"mcp-codemode: --header can only be used when the upstream is an http(s) URL.",
|
|
232
|
+
);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
upstreams = [{ id: "upstream", type: "stdio", command, args: mergedCmdArgs }];
|
|
236
|
+
debugUpstreamSpawn(command, mergedCmdArgs);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
timeoutMs = parseTimeoutMs(options.timeout, "--timeout");
|
|
240
|
+
serverName = options.name as string | undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await runServer({
|
|
244
|
+
upstreams,
|
|
245
|
+
timeoutMs: timeoutMs ?? 60_000,
|
|
246
|
+
serverName: serverName ?? "mcp-codemode",
|
|
247
|
+
addSearch: effectiveAddSearch,
|
|
248
|
+
includeExecuteToolList: effectiveExecuteToolList,
|
|
249
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { UpstreamConfig } from "./server.js";
|
|
4
|
+
|
|
5
|
+
type ConfigRecord = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export interface FileConfig {
|
|
8
|
+
serverName?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
addSearch?: boolean;
|
|
11
|
+
includeExecuteToolList?: boolean;
|
|
12
|
+
upstreams: UpstreamConfig[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type BunToml = {
|
|
16
|
+
parse: (input: string) => unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type BunGlobal = {
|
|
20
|
+
Bun?: {
|
|
21
|
+
TOML?: BunToml;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function asRecord(value: unknown, context: string): ConfigRecord {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
throw new Error(`${context} must be a TOML table/object.`);
|
|
28
|
+
}
|
|
29
|
+
return value as ConfigRecord;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function asOptionalString(
|
|
33
|
+
value: unknown,
|
|
34
|
+
field: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
if (value === undefined) return undefined;
|
|
37
|
+
if (typeof value !== "string") {
|
|
38
|
+
throw new Error(`${field} must be a string.`);
|
|
39
|
+
}
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (trimmed.length === 0) {
|
|
42
|
+
throw new Error(`${field} must not be empty.`);
|
|
43
|
+
}
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asOptionalNumber(
|
|
48
|
+
value: unknown,
|
|
49
|
+
field: string,
|
|
50
|
+
): number | undefined {
|
|
51
|
+
if (value === undefined) return undefined;
|
|
52
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
53
|
+
throw new Error(`${field} must be a finite number.`);
|
|
54
|
+
}
|
|
55
|
+
if (value <= 0) {
|
|
56
|
+
throw new Error(`${field} must be greater than 0.`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asOptionalBoolean(
|
|
62
|
+
value: unknown,
|
|
63
|
+
field: string,
|
|
64
|
+
): boolean | undefined {
|
|
65
|
+
if (value === undefined) return undefined;
|
|
66
|
+
if (typeof value !== "boolean") {
|
|
67
|
+
throw new Error(`${field} must be a boolean.`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseAddSearch(value: unknown): boolean | undefined {
|
|
73
|
+
if (value === undefined) return undefined;
|
|
74
|
+
if (typeof value === "boolean") return value;
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
const normalized = value.trim().toLowerCase();
|
|
77
|
+
if (normalized === "auto") return undefined;
|
|
78
|
+
if (normalized === "always") return true;
|
|
79
|
+
if (normalized === "never") return false;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(
|
|
82
|
+
`add_search must be one of: true, false, "auto", "always", "never".`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseStringArray(value: unknown, field: string): string[] {
|
|
87
|
+
if (value === undefined) return [];
|
|
88
|
+
if (!Array.isArray(value)) {
|
|
89
|
+
throw new Error(`${field} must be an array of strings.`);
|
|
90
|
+
}
|
|
91
|
+
return value.map((item, index) => {
|
|
92
|
+
if (typeof item !== "string") {
|
|
93
|
+
throw new Error(`${field}[${index}] must be a string.`);
|
|
94
|
+
}
|
|
95
|
+
return item;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function expandEnvPlaceholders(value: string, field: string): string {
|
|
100
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => {
|
|
101
|
+
const envValue = process.env[name];
|
|
102
|
+
if (envValue === undefined) {
|
|
103
|
+
throw new Error(`${field} references \${${name}} but ${name} is not set.`);
|
|
104
|
+
}
|
|
105
|
+
return envValue;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseStringRecord(
|
|
110
|
+
value: unknown,
|
|
111
|
+
field: string,
|
|
112
|
+
): Record<string, string> | undefined {
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
const table = asRecord(value, field);
|
|
115
|
+
const parsed: Record<string, string> = {};
|
|
116
|
+
|
|
117
|
+
for (const [key, raw] of Object.entries(table)) {
|
|
118
|
+
const name = key.trim();
|
|
119
|
+
if (name.length === 0) {
|
|
120
|
+
throw new Error(`${field} contains an empty header name.`);
|
|
121
|
+
}
|
|
122
|
+
if (typeof raw !== "string") {
|
|
123
|
+
throw new Error(`${field}.${name} must be a string.`);
|
|
124
|
+
}
|
|
125
|
+
parsed[name] = expandEnvPlaceholders(raw, `${field}.${name}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseUpstreams(value: unknown): UpstreamConfig[] {
|
|
132
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
133
|
+
throw new Error(`servers must be a non-empty array of TOML tables.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const seenIds = new Set<string>();
|
|
137
|
+
const upstreams: UpstreamConfig[] = [];
|
|
138
|
+
|
|
139
|
+
value.forEach((entry, index) => {
|
|
140
|
+
const table = asRecord(entry, `servers[${index}]`);
|
|
141
|
+
const id =
|
|
142
|
+
asOptionalString(table["id"], `servers[${index}].id`) ??
|
|
143
|
+
`server${index + 1}`;
|
|
144
|
+
if (seenIds.has(id)) {
|
|
145
|
+
throw new Error(`Duplicate server id "${id}". Each server id must be unique.`);
|
|
146
|
+
}
|
|
147
|
+
seenIds.add(id);
|
|
148
|
+
|
|
149
|
+
const type = asOptionalString(table["type"], `servers[${index}].type`);
|
|
150
|
+
if (!type) {
|
|
151
|
+
throw new Error(`servers[${index}].type is required.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === "stdio") {
|
|
155
|
+
const command = asOptionalString(
|
|
156
|
+
table["command"],
|
|
157
|
+
`servers[${index}].command`,
|
|
158
|
+
);
|
|
159
|
+
if (!command) {
|
|
160
|
+
throw new Error(`servers[${index}].command is required for stdio servers.`);
|
|
161
|
+
}
|
|
162
|
+
const args = parseStringArray(table["args"], `servers[${index}].args`);
|
|
163
|
+
upstreams.push({ id, type: "stdio", command, args });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (type === "http") {
|
|
168
|
+
const url = asOptionalString(table["url"], `servers[${index}].url`);
|
|
169
|
+
if (!url) {
|
|
170
|
+
throw new Error(`servers[${index}].url is required for http servers.`);
|
|
171
|
+
}
|
|
172
|
+
const headers = parseStringRecord(
|
|
173
|
+
table["headers"],
|
|
174
|
+
`servers[${index}].headers`,
|
|
175
|
+
);
|
|
176
|
+
let parsed: URL;
|
|
177
|
+
try {
|
|
178
|
+
parsed = new URL(url);
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error(`servers[${index}].url is not a valid URL.`);
|
|
181
|
+
}
|
|
182
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`servers[${index}].url must start with http:// or https://.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
upstreams.push({
|
|
188
|
+
id,
|
|
189
|
+
type: "http",
|
|
190
|
+
url: parsed.toString(),
|
|
191
|
+
headers,
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(
|
|
197
|
+
`servers[${index}].type must be "stdio" or "http", got "${type}".`,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return upstreams;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseToml(text: string, source: string): unknown {
|
|
205
|
+
const parser = (globalThis as BunGlobal).Bun?.TOML?.parse;
|
|
206
|
+
if (!parser) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`TOML parsing is unavailable (Bun.TOML.parse not found) while loading ${source}.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
return parser(text);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Failed to parse TOML at ${source}: ${err instanceof Error ? err.message : String(err)}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function loadTomlConfig(configPath: string): Promise<FileConfig> {
|
|
221
|
+
const absolutePath = resolve(configPath);
|
|
222
|
+
const raw = await readFile(absolutePath, "utf8");
|
|
223
|
+
const parsed = parseToml(raw, absolutePath);
|
|
224
|
+
const root = asRecord(parsed, "config");
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
serverName: asOptionalString(root["name"], "name"),
|
|
228
|
+
timeoutMs: asOptionalNumber(root["timeout_ms"], "timeout_ms"),
|
|
229
|
+
addSearch: parseAddSearch(root["add_search"]),
|
|
230
|
+
includeExecuteToolList: asOptionalBoolean(
|
|
231
|
+
root["include_execute_tool_list"],
|
|
232
|
+
"include_execute_tool_list",
|
|
233
|
+
),
|
|
234
|
+
upstreams: parseUpstreams(root["servers"]),
|
|
235
|
+
};
|
|
236
|
+
}
|
package/src/executor.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local in-process executor for codemode.
|
|
3
|
+
*
|
|
4
|
+
* Runs LLM-generated async arrow functions in Node's vm module with a
|
|
5
|
+
* captured `codemode` proxy that routes calls to the real tool functions.
|
|
6
|
+
*
|
|
7
|
+
* In production, DynamicWorkerExecutor (Cloudflare Workers) provides
|
|
8
|
+
* stronger isolation. For local use with Playwright MCP, vm is sufficient.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as vm from "node:vm";
|
|
12
|
+
|
|
13
|
+
export type ToolFns = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
14
|
+
|
|
15
|
+
export interface ExecuteResult {
|
|
16
|
+
result: unknown;
|
|
17
|
+
error?: string;
|
|
18
|
+
logs: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class LocalExecutor {
|
|
22
|
+
private timeoutMs: number;
|
|
23
|
+
|
|
24
|
+
constructor({ timeoutMs = 60_000 } = {}) {
|
|
25
|
+
// Playwright operations can be slow (page loads, etc.) — generous timeout
|
|
26
|
+
this.timeoutMs = timeoutMs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async execute(code: string, fns: ToolFns): Promise<ExecuteResult> {
|
|
30
|
+
const logs: string[] = [];
|
|
31
|
+
|
|
32
|
+
const capturedConsole = {
|
|
33
|
+
log: (...args: unknown[]) => {
|
|
34
|
+
const msg = args.map(a =>
|
|
35
|
+
typeof a === "object" ? JSON.stringify(a) : String(a)
|
|
36
|
+
).join(" ");
|
|
37
|
+
logs.push(msg);
|
|
38
|
+
process.stdout.write(` [log] ${msg}\n`);
|
|
39
|
+
},
|
|
40
|
+
warn: (...args: unknown[]) => {
|
|
41
|
+
const msg = args.map(a => String(a)).join(" ");
|
|
42
|
+
logs.push(`[warn] ${msg}`);
|
|
43
|
+
process.stdout.write(` [warn] ${msg}\n`);
|
|
44
|
+
},
|
|
45
|
+
error: (...args: unknown[]) => {
|
|
46
|
+
const msg = args.map(a => String(a)).join(" ");
|
|
47
|
+
logs.push(`[error] ${msg}`);
|
|
48
|
+
process.stdout.write(` [error] ${msg}\n`);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Build codemode proxy — calls the real tool fns directly
|
|
53
|
+
const codemode = new Proxy({} as Record<string, unknown>, {
|
|
54
|
+
get: (_target, prop: string) => {
|
|
55
|
+
const fn = fns[prop];
|
|
56
|
+
if (!fn) {
|
|
57
|
+
return () => Promise.reject(new Error(`Tool "${prop}" not found. Available: ${Object.keys(fns).join(", ")}`));
|
|
58
|
+
}
|
|
59
|
+
return fn;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const normalizedCode = normalizeCode(code);
|
|
64
|
+
|
|
65
|
+
// Use vm.createContext so we can pass in codemode + captured console
|
|
66
|
+
// without polluting global scope. We include fetch/setTimeout for helpers.
|
|
67
|
+
const g = globalThis as Record<string, unknown>;
|
|
68
|
+
const sandbox = {
|
|
69
|
+
codemode,
|
|
70
|
+
console: capturedConsole,
|
|
71
|
+
fetch: g["fetch"],
|
|
72
|
+
setTimeout: g["setTimeout"],
|
|
73
|
+
clearTimeout: g["clearTimeout"],
|
|
74
|
+
Promise: g["Promise"],
|
|
75
|
+
JSON: g["JSON"],
|
|
76
|
+
Math: g["Math"],
|
|
77
|
+
URL: g["URL"],
|
|
78
|
+
Error: g["Error"],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const context = vm.createContext(sandbox);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Script returns a Promise (the async arrow fn invocation).
|
|
85
|
+
// vm timeout only covers sync execution; we wrap in Promise.race for async.
|
|
86
|
+
const script = new vm.Script(`(${normalizedCode})()`, {
|
|
87
|
+
filename: "codemode-exec.js",
|
|
88
|
+
});
|
|
89
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
90
|
+
setTimeout(() => reject(new Error(`Execution timed out after ${this.timeoutMs}ms`)), this.timeoutMs)
|
|
91
|
+
);
|
|
92
|
+
const result = await Promise.race([script.runInContext(context), timeoutPromise]);
|
|
93
|
+
return { result, logs };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
result: undefined,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
logs,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Ensures code is an async arrow function expression so we can call it.
|
|
106
|
+
* Mirrors codemode's normalizeCode behaviour (without the acorn dependency).
|
|
107
|
+
*/
|
|
108
|
+
function normalizeCode(code: string): string {
|
|
109
|
+
const trimmed = code.trim();
|
|
110
|
+
|
|
111
|
+
// Already an async arrow function
|
|
112
|
+
if (/^async\s*(\(|[a-zA-Z_$])/.test(trimmed)) return trimmed;
|
|
113
|
+
|
|
114
|
+
// Wrap bare body in async arrow function
|
|
115
|
+
return `async () => {\n${trimmed}\n}`;
|
|
116
|
+
}
|