webmcp-codegen 0.3.2 → 0.3.3
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/dist/chunk-MUTXYBL6.js +1255 -0
- package/dist/chunk-MUTXYBL6.js.map +1 -0
- package/dist/cli.js +100 -1329
- package/dist/cli.js.map +1 -1
- package/dist/dev/server.d.ts +25 -0
- package/dist/dev/server.js +12 -0
- package/dist/dev/server.js.map +1 -0
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
findSpecs,
|
|
4
|
+
resolveSetup,
|
|
5
|
+
saveDataFile,
|
|
6
|
+
startDevServer
|
|
7
|
+
} from "./chunk-MUTXYBL6.js";
|
|
2
8
|
import {
|
|
3
9
|
CONFIG_FILE_NAMES,
|
|
4
|
-
loadConfig,
|
|
5
10
|
runGenerate
|
|
6
11
|
} from "./chunk-MJQ5B6HB.js";
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
} from "./chunk-EAKYM4YS.js";
|
|
10
|
-
import {
|
|
11
|
-
openapi
|
|
12
|
-
} from "./chunk-3LTHWIAP.js";
|
|
12
|
+
import "./chunk-EAKYM4YS.js";
|
|
13
|
+
import "./chunk-3LTHWIAP.js";
|
|
13
14
|
import "./chunk-FWSATV7C.js";
|
|
14
15
|
import "./chunk-KSQMJERY.js";
|
|
15
16
|
|
|
16
17
|
// src/cli.ts
|
|
17
|
-
import { existsSync
|
|
18
|
-
import { writeFile as
|
|
19
|
-
import { join as
|
|
18
|
+
import { existsSync } from "fs";
|
|
19
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
20
|
+
import { join as join2 } from "path";
|
|
20
21
|
import { parseArgs } from "util";
|
|
21
22
|
|
|
22
23
|
// src/cli-output.ts
|
|
@@ -24,15 +25,12 @@ var ESC = "\x1B[";
|
|
|
24
25
|
var RESET = `${ESC}0m`;
|
|
25
26
|
var BOLD = `${ESC}1m`;
|
|
26
27
|
var DIM = `${ESC}2m`;
|
|
27
|
-
var ITALIC = `${ESC}3m`;
|
|
28
28
|
var FG = {
|
|
29
29
|
red: `${ESC}31m`,
|
|
30
30
|
green: `${ESC}32m`,
|
|
31
31
|
yellow: `${ESC}33m`,
|
|
32
32
|
blue: `${ESC}34m`,
|
|
33
|
-
magenta: `${ESC}35m`,
|
|
34
33
|
cyan: `${ESC}36m`,
|
|
35
|
-
white: `${ESC}37m`,
|
|
36
34
|
gray: `${ESC}90m`
|
|
37
35
|
};
|
|
38
36
|
function c(color, text) {
|
|
@@ -44,85 +42,82 @@ function bold(text) {
|
|
|
44
42
|
function dim(text) {
|
|
45
43
|
return `${DIM}${text}${RESET}`;
|
|
46
44
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
function header(title, subtitle) {
|
|
51
|
-
const line = "\u2500".repeat(Math.max(title.length, subtitle.length) + 4);
|
|
52
|
-
return `${c("cyan", line)}
|
|
53
|
-
${bold(title)}
|
|
54
|
-
${subtitle}
|
|
55
|
-
${c("cyan", line)}`;
|
|
56
|
-
}
|
|
57
|
-
function badge(risk) {
|
|
58
|
-
switch (risk) {
|
|
59
|
-
case "read":
|
|
60
|
-
return c("green", "[read]");
|
|
61
|
-
case "write":
|
|
62
|
-
return c("yellow", "[write]");
|
|
63
|
-
case "destructive":
|
|
64
|
-
return c("red", "[destructive]");
|
|
65
|
-
default:
|
|
66
|
-
return `[${risk}]`;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
function groupFindings(findings) {
|
|
70
|
-
const groups = {};
|
|
45
|
+
function summarizeFindings(findings) {
|
|
46
|
+
const counts = { auth: 0, admin: 0, pii: 0, postAsRead: 0, other: 0 };
|
|
71
47
|
for (const f of findings) {
|
|
72
|
-
const
|
|
73
|
-
|
|
48
|
+
const msg = f.message.toLowerCase();
|
|
49
|
+
if (msg.includes("sign-in") || msg.includes("auth") || msg.includes("session")) {
|
|
50
|
+
counts.auth++;
|
|
51
|
+
} else if (msg.includes("admin")) {
|
|
52
|
+
counts.admin++;
|
|
53
|
+
} else if (msg.includes("pii") || msg.includes("email")) {
|
|
54
|
+
counts.pii++;
|
|
55
|
+
} else if (msg.includes("post") && msg.includes("read")) {
|
|
56
|
+
counts.postAsRead++;
|
|
57
|
+
} else {
|
|
58
|
+
counts.other++;
|
|
59
|
+
}
|
|
74
60
|
}
|
|
75
|
-
return
|
|
61
|
+
return counts;
|
|
76
62
|
}
|
|
77
63
|
function renderSummary(result, setup, _cwd, wiring) {
|
|
78
|
-
const { tools, findings } = result;
|
|
64
|
+
const { tools, findings, skipped } = result;
|
|
79
65
|
const reads = tools.filter((t) => t.sideEffect === "read").length;
|
|
80
66
|
const writes = tools.filter((t) => t.sideEffect === "write").length;
|
|
81
67
|
const destructives = tools.filter((t) => t.sideEffect === "destructive").length;
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
68
|
+
const enabled = tools.filter((t) => t.enabledByDefault).length;
|
|
69
|
+
const findingCounts = summarizeFindings(findings);
|
|
70
|
+
const totalFindings = findings.length;
|
|
85
71
|
console.log("");
|
|
86
|
-
console.log(
|
|
72
|
+
console.log(` ${bold("webmcp-codegen")}`);
|
|
73
|
+
console.log(dim(` ${setup.label}`));
|
|
87
74
|
console.log("");
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
75
|
+
console.log(` ${c("green", "\u2713")} ${bold(`${tools.length} tools generated`)}`);
|
|
76
|
+
console.log(dim(` ${enabled} ready to use, ${tools.length - enabled} start disabled`));
|
|
77
|
+
if (skipped.length > 0) {
|
|
78
|
+
console.log(dim(` ${skipped.length} skipped (webhooks and excluded endpoints)`));
|
|
79
|
+
}
|
|
80
|
+
console.log("");
|
|
81
|
+
if (totalFindings > 0) {
|
|
82
|
+
console.log(` ${c("yellow", "!")} ${bold(`${totalFindings} safety note${totalFindings === 1 ? "" : "s"}`)}`);
|
|
83
|
+
if (findingCounts.auth > 0) {
|
|
84
|
+
console.log(dim(` ${findingCounts.auth} auth endpoint${findingCounts.auth === 1 ? "" : "s"} disabled (agents shouldn't sign in)`));
|
|
85
|
+
}
|
|
86
|
+
if (findingCounts.admin > 0) {
|
|
87
|
+
console.log(dim(` ${findingCounts.admin} admin endpoint${findingCounts.admin === 1 ? "" : "s"} disabled (review each before enabling)`));
|
|
88
|
+
}
|
|
89
|
+
if (findingCounts.pii > 0) {
|
|
90
|
+
console.log(dim(` ${findingCounts.pii} endpoint${findingCounts.pii === 1 ? "" : "s"} may return personal data`));
|
|
91
|
+
}
|
|
92
|
+
if (findingCounts.postAsRead > 0) {
|
|
93
|
+
console.log(dim(` ${findingCounts.postAsRead} POST endpoint${findingCounts.postAsRead === 1 ? "" : "s"} treated as read-only (verify this is correct)`));
|
|
94
|
+
}
|
|
95
|
+
console.log(dim(` Run with --verbose to see all details`));
|
|
96
|
+
console.log("");
|
|
105
97
|
}
|
|
106
98
|
const outDir = setup.config.generate[0]?.outDir ?? "src/webmcp";
|
|
107
|
-
console.log(` ${c("cyan", "\u2192")} ${bold(outDir)
|
|
108
|
-
`);
|
|
99
|
+
console.log(` ${c("cyan", "\u2192")} ${bold("Files")} ${outDir}`);
|
|
109
100
|
if (wiring && !wiring.alreadyWired) {
|
|
110
|
-
console.log(` ${c("
|
|
111
|
-
`);
|
|
101
|
+
console.log(` ${c("cyan", "\u2192")} ${bold("Registration")} wired into your app`);
|
|
112
102
|
}
|
|
113
|
-
console.log(
|
|
114
|
-
`);
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(` ${bold("Next:")} ${c("cyan", "npx webmcp-codegen dev")}`);
|
|
105
|
+
console.log(dim(" Review your tools, edit descriptions, test them live"));
|
|
106
|
+
console.log("");
|
|
107
|
+
console.log(dim(` Docs: https://webmcp-codegen.vercel.app/docs`));
|
|
108
|
+
console.log("");
|
|
115
109
|
}
|
|
116
110
|
function renderVerbose(result, setup, _cwd) {
|
|
117
111
|
const { tools, findings, skipped } = result;
|
|
118
112
|
console.log("");
|
|
119
|
-
console.log(bold(`webmcp-codegen
|
|
113
|
+
console.log(bold(`webmcp-codegen`));
|
|
114
|
+
console.log(dim(`${tools.length} tools from ${setup.label}`));
|
|
120
115
|
console.log("");
|
|
121
116
|
if (skipped.length > 0) {
|
|
122
117
|
console.log(c("gray", "Skipped:"));
|
|
123
118
|
for (const s of skipped) {
|
|
124
119
|
console.log(` ${dim(s.ref)}`);
|
|
125
|
-
console.log(` ${
|
|
120
|
+
console.log(` ${dim(s.reason)}`);
|
|
126
121
|
}
|
|
127
122
|
console.log("");
|
|
128
123
|
}
|
|
@@ -131,18 +126,23 @@ function renderVerbose(result, setup, _cwd) {
|
|
|
131
126
|
write: tools.filter((t) => t.sideEffect === "write"),
|
|
132
127
|
destructive: tools.filter((t) => t.sideEffect === "destructive")
|
|
133
128
|
};
|
|
129
|
+
const riskLabels = {
|
|
130
|
+
read: c("green", "Read-only"),
|
|
131
|
+
write: c("yellow", "Write"),
|
|
132
|
+
destructive: c("red", "Destructive")
|
|
133
|
+
};
|
|
134
134
|
for (const [risk, group] of Object.entries(byRisk)) {
|
|
135
135
|
if (group.length === 0) continue;
|
|
136
|
-
console.log(
|
|
136
|
+
console.log(riskLabels[risk] ?? risk);
|
|
137
137
|
for (const tool of group) {
|
|
138
|
-
const
|
|
139
|
-
console.log(` ${tool.name}${
|
|
138
|
+
const status = tool.enabledByDefault ? "" : dim(" (disabled)");
|
|
139
|
+
console.log(` ${tool.name}${status}`);
|
|
140
140
|
if (tool.description) console.log(` ${dim(tool.description)}`);
|
|
141
141
|
}
|
|
142
142
|
console.log("");
|
|
143
143
|
}
|
|
144
144
|
if (findings.length > 0) {
|
|
145
|
-
console.log(bold("
|
|
145
|
+
console.log(bold("Safety notes:"));
|
|
146
146
|
for (const f of findings) {
|
|
147
147
|
const icon = f.level === "error" ? c("red", "\u2716") : c("yellow", "\u26A0");
|
|
148
148
|
const where = f.tool ? dim(` (${f.tool})`) : "";
|
|
@@ -150,1243 +150,14 @@ function renderVerbose(result, setup, _cwd) {
|
|
|
150
150
|
}
|
|
151
151
|
console.log("");
|
|
152
152
|
}
|
|
153
|
-
console.log(`
|
|
154
|
-
`);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// src/data-file.ts
|
|
158
|
-
import { readFile, writeFile } from "fs/promises";
|
|
159
|
-
import { join } from "path";
|
|
160
|
-
var DATA_FILE_NAME = ".webmcp-codegen.json";
|
|
161
|
-
async function loadDataFile(cwd) {
|
|
162
|
-
try {
|
|
163
|
-
const parsed = JSON.parse(await readFile(join(cwd, DATA_FILE_NAME), "utf8"));
|
|
164
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
165
|
-
} catch {
|
|
166
|
-
return {};
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
async function saveDataFile(cwd, patch) {
|
|
170
|
-
const current = await loadDataFile(cwd);
|
|
171
|
-
const next = { ...current, ...patch };
|
|
172
|
-
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
|
173
|
-
await writeFile(join(cwd, DATA_FILE_NAME), `${JSON.stringify(next, null, 2)}
|
|
174
|
-
`, "utf8");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// src/detect.ts
|
|
178
|
-
import { readdir } from "fs/promises";
|
|
179
|
-
import { join as join2, relative } from "path";
|
|
180
|
-
var SPEC_FILE_PATTERN = /^(openapi|swagger|api)\.(ya?ml|json)$/i;
|
|
181
|
-
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
182
|
-
"node_modules",
|
|
183
|
-
".git",
|
|
184
|
-
".turbo",
|
|
185
|
-
".next",
|
|
186
|
-
"dist",
|
|
187
|
-
"build",
|
|
188
|
-
"coverage"
|
|
189
|
-
]);
|
|
190
|
-
var MAX_DEPTH = 5;
|
|
191
|
-
async function findSpecs(cwd) {
|
|
192
|
-
const found = [];
|
|
193
|
-
async function walk(dir, depth) {
|
|
194
|
-
if (depth > MAX_DEPTH) return;
|
|
195
|
-
let entries;
|
|
196
|
-
try {
|
|
197
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
198
|
-
} catch {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
for (const entry of entries) {
|
|
202
|
-
if (entry.isDirectory()) {
|
|
203
|
-
if (!IGNORED_DIRS.has(entry.name)) await walk(join2(dir, entry.name), depth + 1);
|
|
204
|
-
} else if (SPEC_FILE_PATTERN.test(entry.name)) {
|
|
205
|
-
found.push({ path: join2(dir, entry.name), depth });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
await walk(cwd, 0);
|
|
210
|
-
return found.sort((a, b) => a.depth - b.depth).map((entry) => relative(cwd, entry.path));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// src/dev/server.ts
|
|
214
|
-
import { spawn } from "child_process";
|
|
215
|
-
import { createServer } from "http";
|
|
216
|
-
|
|
217
|
-
// src/setup.ts
|
|
218
|
-
import { existsSync } from "fs";
|
|
219
|
-
import { basename, join as join4 } from "path";
|
|
220
|
-
import { createInterface } from "readline/promises";
|
|
221
|
-
|
|
222
|
-
// src/detect-app.ts
|
|
223
|
-
import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
|
|
224
|
-
import { join as join3 } from "path";
|
|
225
|
-
var FRAMEWORKS = [
|
|
226
|
-
{ dep: "next", framework: "next" },
|
|
227
|
-
{ dep: "nuxt", framework: "nuxt" },
|
|
228
|
-
{ dep: "@sveltejs/kit", framework: "sveltekit" }
|
|
229
|
-
];
|
|
230
|
-
async function findWebApps(cwd) {
|
|
231
|
-
const packageDirs = await findPackageDirs(cwd);
|
|
232
|
-
const apps = [];
|
|
233
|
-
for (const dir of packageDirs) {
|
|
234
|
-
const pkg = await readPackageJson(join3(cwd, dir));
|
|
235
|
-
if (!pkg) continue;
|
|
236
|
-
const deps = {
|
|
237
|
-
...pkg.dependencies,
|
|
238
|
-
...pkg.devDependencies
|
|
239
|
-
};
|
|
240
|
-
const known = FRAMEWORKS.find(({ dep }) => deps[dep]);
|
|
241
|
-
const framework = known?.framework ?? (deps.react && deps.vite ? "vite-react" : void 0);
|
|
242
|
-
if (framework) apps.push({ dir, framework });
|
|
243
|
-
}
|
|
244
|
-
return apps.sort((a, b) => score(b) - score(a));
|
|
245
|
-
function score(app) {
|
|
246
|
-
return (app.framework === "unknown" ? 0 : 10) + (/(^|\/)(web|app|frontend|client)$/.test(app.dir) ? 2 : 0);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
async function findPackageDirs(cwd) {
|
|
250
|
-
const dirs = [];
|
|
251
|
-
const root = await readPackageJson(join3(cwd, ""));
|
|
252
|
-
if (root) {
|
|
253
|
-
dirs.push(".");
|
|
254
|
-
for (const pattern of await workspaceGlobs(cwd, root)) {
|
|
255
|
-
dirs.push(...await expandShallowGlob(cwd, pattern));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return [...new Set(dirs)];
|
|
259
|
-
}
|
|
260
|
-
async function workspaceGlobs(cwd, rootPkg) {
|
|
261
|
-
const workspaces = rootPkg.workspaces;
|
|
262
|
-
if (Array.isArray(workspaces)) return workspaces;
|
|
263
|
-
if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
|
|
264
|
-
return workspaces.packages;
|
|
265
|
-
}
|
|
266
|
-
return readPnpmWorkspaceGlobs(cwd);
|
|
267
|
-
}
|
|
268
|
-
async function readPnpmWorkspaceGlobs(cwd) {
|
|
269
|
-
try {
|
|
270
|
-
const text = await readFile2(join3(cwd, "pnpm-workspace.yaml"), "utf8");
|
|
271
|
-
const packagesBlock = /^packages:\s*\n((?:\s+-\s+.+\n?)+)/m.exec(text);
|
|
272
|
-
if (!packagesBlock) return [];
|
|
273
|
-
return [...packagesBlock[1].matchAll(/^\s+-\s+['"]?([^'"\n]+?)['"]?\s*$/gm)].map(
|
|
274
|
-
(match) => match[1]
|
|
275
|
-
);
|
|
276
|
-
} catch {
|
|
277
|
-
return [];
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
async function expandShallowGlob(cwd, pattern) {
|
|
281
|
-
const starAt = pattern.indexOf("*");
|
|
282
|
-
const base = starAt === -1 ? pattern : pattern.slice(0, starAt).replace(/\/$/, "");
|
|
283
|
-
if (starAt === -1) return [base];
|
|
284
|
-
try {
|
|
285
|
-
const entries = await readdir2(join3(cwd, base), { withFileTypes: true });
|
|
286
|
-
return entries.filter((entry) => entry.isDirectory()).map((entry) => `${base}/${entry.name}`);
|
|
287
|
-
} catch {
|
|
288
|
-
return [];
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
async function readPackageJson(dir) {
|
|
292
|
-
try {
|
|
293
|
-
return JSON.parse(await readFile2(join3(dir, "package.json"), "utf8"));
|
|
294
|
-
} catch {
|
|
295
|
-
return void 0;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// src/setup.ts
|
|
300
|
-
async function resolveSetup(cwd, flags) {
|
|
301
|
-
const hasConfigFile = flags.configPath ? existsSync(join4(cwd, flags.configPath)) : CONFIG_FILE_NAMES.some((name) => existsSync(join4(cwd, name)));
|
|
302
|
-
if (hasConfigFile) {
|
|
303
|
-
const { config, path } = await loadConfig(cwd, flags.configPath);
|
|
304
|
-
if (flags.spec || flags.out) {
|
|
305
|
-
console.warn(`Note: --spec/--out are ignored; ${basename(path)} is in charge here.`);
|
|
306
|
-
}
|
|
307
|
-
const data2 = await loadDataFile(cwd);
|
|
308
|
-
const apps = await findWebApps(cwd);
|
|
309
|
-
const app2 = apps.find((candidate) => candidate.dir === data2.app) ?? apps[0];
|
|
310
|
-
return { config, label: basename(path), app: app2, fromConfigFile: true, remember: {} };
|
|
311
|
-
}
|
|
312
|
-
if (flags.configPath) {
|
|
313
|
-
throw new Error(`No config file at "${flags.configPath}".`);
|
|
314
|
-
}
|
|
315
|
-
const data = await loadDataFile(cwd);
|
|
316
|
-
const spec = flags.spec ?? data.spec ?? await detectSpec(cwd);
|
|
317
|
-
let app;
|
|
318
|
-
if (!flags.out) {
|
|
319
|
-
const apps = await findWebApps(cwd);
|
|
320
|
-
const remembered = apps.find((candidate) => candidate.dir === data.app);
|
|
321
|
-
if (remembered) {
|
|
322
|
-
app = remembered;
|
|
323
|
-
} else if (apps.length === 1) {
|
|
324
|
-
app = apps[0];
|
|
325
|
-
console.log(`Found your web app: ${app?.dir} (${app?.framework})`);
|
|
326
|
-
} else if (apps.length > 1) {
|
|
327
|
-
app = await askWhichApp(apps);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
const outDir = flags.out ?? (app && app.dir !== "." ? `${app.dir}/src/webmcp` : "./src/webmcp");
|
|
331
|
-
return {
|
|
332
|
-
config: { sources: [openapi({ spec })], generate: [js({ outDir })] },
|
|
333
|
-
label: flags.spec ? `--spec ${spec}` : `detected ${spec}`,
|
|
334
|
-
app,
|
|
335
|
-
fromConfigFile: false,
|
|
336
|
-
remember: { spec, app: app?.dir }
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
async function askWhichApp(apps) {
|
|
340
|
-
if (!process.stdin.isTTY) {
|
|
341
|
-
const first = apps[0];
|
|
342
|
-
console.log(`Several packages look like web apps; using ${first.dir}. Override with --out.`);
|
|
343
|
-
return first;
|
|
344
|
-
}
|
|
345
|
-
console.log("Several packages look like the web app. Which one should the tools live in?");
|
|
346
|
-
apps.forEach((app, index) => {
|
|
347
|
-
console.log(` ${index + 1}. ${app.dir} (${app.framework})${index === 0 ? " [default]" : ""}`);
|
|
348
|
-
});
|
|
349
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
350
|
-
try {
|
|
351
|
-
const answer = await rl.question("Choice [1]: ");
|
|
352
|
-
const picked = Number.parseInt(answer.trim() || "1", 10);
|
|
353
|
-
return apps[picked - 1] ?? apps[0];
|
|
354
|
-
} finally {
|
|
355
|
-
rl.close();
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
async function detectSpec(cwd) {
|
|
359
|
-
const specs = await findSpecs(cwd);
|
|
360
|
-
if (specs.length === 0) {
|
|
361
|
-
throw new Error(
|
|
362
|
-
"No OpenAPI spec found in this project.\nPoint at one: npx webmcp-codegen generate --spec path/to/openapi.json"
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
if (specs.length > 1) {
|
|
366
|
-
const list = specs.map((spec) => ` - ${spec}`).join("\n");
|
|
367
|
-
throw new Error(
|
|
368
|
-
`Found ${specs.length} API specs:
|
|
369
|
-
${list}
|
|
370
|
-
|
|
371
|
-
Pick one: npx webmcp-codegen generate --spec ${specs[0]}`
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
console.log(`Detected ${specs[0]} (override with --spec)`);
|
|
375
|
-
return specs[0];
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// src/dev/ui.ts
|
|
379
|
-
function dashboardHtml() {
|
|
380
|
-
return `<!DOCTYPE html>
|
|
381
|
-
<html lang="en">
|
|
382
|
-
<head>
|
|
383
|
-
<meta charset="utf-8" />
|
|
384
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
385
|
-
<title>webmcp-codegen</title>
|
|
386
|
-
<style>
|
|
387
|
-
:root {
|
|
388
|
-
--baseline: #0a0b0f;
|
|
389
|
-
--surface: #10131a;
|
|
390
|
-
--surface-raised: #161a23;
|
|
391
|
-
--line: #1e2330;
|
|
392
|
-
--line-subtle: #161a23;
|
|
393
|
-
--ink: #e9ecf2;
|
|
394
|
-
--dim: #9aa3b2;
|
|
395
|
-
--faint: #5d6575;
|
|
396
|
-
--ghost: #3b4150;
|
|
397
|
-
--accent: #58a6ff;
|
|
398
|
-
--accent-dim: rgba(88, 166, 255, 0.15);
|
|
399
|
-
--signal: #e3b341;
|
|
400
|
-
--signal-dim: rgba(227, 179, 65, 0.15);
|
|
401
|
-
--fault: #f47067;
|
|
402
|
-
--fault-dim: rgba(244, 112, 103, 0.15);
|
|
403
|
-
--sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
404
|
-
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
405
|
-
}
|
|
406
|
-
* { box-sizing: border-box; }
|
|
407
|
-
html, body { margin: 0; height: 100%; }
|
|
408
|
-
body {
|
|
409
|
-
background: var(--baseline);
|
|
410
|
-
color: var(--ink);
|
|
411
|
-
font-family: var(--sans);
|
|
412
|
-
font-size: 14px;
|
|
413
|
-
-webkit-font-smoothing: antialiased;
|
|
414
|
-
overflow: hidden;
|
|
415
|
-
}
|
|
416
|
-
::selection { background: var(--accent); color: var(--baseline); }
|
|
417
|
-
|
|
418
|
-
/* Layout */
|
|
419
|
-
.app { display: flex; height: 100vh; }
|
|
420
|
-
.sidebar {
|
|
421
|
-
width: 320px;
|
|
422
|
-
min-width: 320px;
|
|
423
|
-
border-right: 1px solid var(--line);
|
|
424
|
-
display: flex;
|
|
425
|
-
flex-direction: column;
|
|
426
|
-
background: var(--surface);
|
|
427
|
-
}
|
|
428
|
-
.main {
|
|
429
|
-
flex: 1;
|
|
430
|
-
overflow-y: auto;
|
|
431
|
-
background: var(--baseline);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/* Sidebar header */
|
|
435
|
-
.sidebar-header {
|
|
436
|
-
padding: 20px 20px 16px;
|
|
437
|
-
border-bottom: 1px solid var(--line-subtle);
|
|
438
|
-
}
|
|
439
|
-
.brand {
|
|
440
|
-
display: flex;
|
|
441
|
-
align-items: center;
|
|
442
|
-
gap: 10px;
|
|
443
|
-
font-weight: 600;
|
|
444
|
-
font-size: 15px;
|
|
445
|
-
margin-bottom: 4px;
|
|
446
|
-
}
|
|
447
|
-
.brand-mark {
|
|
448
|
-
width: 24px;
|
|
449
|
-
height: 24px;
|
|
450
|
-
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
|
451
|
-
border-radius: 6px;
|
|
452
|
-
display: flex;
|
|
453
|
-
align-items: center;
|
|
454
|
-
justify-content: center;
|
|
455
|
-
font-size: 12px;
|
|
456
|
-
font-weight: 700;
|
|
457
|
-
color: white;
|
|
458
|
-
}
|
|
459
|
-
.brand-sub {
|
|
460
|
-
color: var(--faint);
|
|
461
|
-
font-size: 12px;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/* Search */
|
|
465
|
-
.search-wrap {
|
|
466
|
-
padding: 12px 16px;
|
|
467
|
-
border-bottom: 1px solid var(--line-subtle);
|
|
468
|
-
}
|
|
469
|
-
.search {
|
|
470
|
-
width: 100%;
|
|
471
|
-
background: var(--surface-raised);
|
|
472
|
-
border: 1px solid var(--line);
|
|
473
|
-
border-radius: 6px;
|
|
474
|
-
padding: 8px 12px 8px 32px;
|
|
475
|
-
color: var(--ink);
|
|
476
|
-
font-size: 13px;
|
|
477
|
-
font-family: inherit;
|
|
478
|
-
position: relative;
|
|
479
|
-
}
|
|
480
|
-
.search:focus {
|
|
481
|
-
outline: none;
|
|
482
|
-
border-color: var(--accent);
|
|
483
|
-
}
|
|
484
|
-
.search-icon {
|
|
485
|
-
position: absolute;
|
|
486
|
-
left: 28px;
|
|
487
|
-
top: 50%;
|
|
488
|
-
transform: translateY(-50%);
|
|
489
|
-
color: var(--faint);
|
|
490
|
-
pointer-events: none;
|
|
491
|
-
}
|
|
492
|
-
.search-wrap { position: relative; }
|
|
493
|
-
|
|
494
|
-
/* Tool list */
|
|
495
|
-
.tool-list {
|
|
496
|
-
flex: 1;
|
|
497
|
-
overflow-y: auto;
|
|
498
|
-
padding: 8px 0;
|
|
499
|
-
}
|
|
500
|
-
.tool-group {
|
|
501
|
-
padding: 8px 16px 4px;
|
|
502
|
-
font-size: 11px;
|
|
503
|
-
font-weight: 600;
|
|
504
|
-
text-transform: uppercase;
|
|
505
|
-
letter-spacing: 0.05em;
|
|
506
|
-
color: var(--faint);
|
|
507
|
-
}
|
|
508
|
-
.tool {
|
|
509
|
-
display: flex;
|
|
510
|
-
align-items: center;
|
|
511
|
-
gap: 10px;
|
|
512
|
-
width: 100%;
|
|
513
|
-
padding: 8px 16px;
|
|
514
|
-
border: none;
|
|
515
|
-
background: none;
|
|
516
|
-
color: var(--ink);
|
|
517
|
-
font-size: 13px;
|
|
518
|
-
font-family: var(--mono);
|
|
519
|
-
text-align: left;
|
|
520
|
-
cursor: pointer;
|
|
521
|
-
transition: background 0.1s;
|
|
522
|
-
}
|
|
523
|
-
.tool:hover { background: var(--surface-raised); }
|
|
524
|
-
.tool[aria-selected="true"] {
|
|
525
|
-
background: var(--accent-dim);
|
|
526
|
-
border-right: 2px solid var(--accent);
|
|
527
|
-
}
|
|
528
|
-
.tool-indicator {
|
|
529
|
-
width: 6px;
|
|
530
|
-
height: 6px;
|
|
531
|
-
border-radius: 50%;
|
|
532
|
-
flex-shrink: 0;
|
|
533
|
-
}
|
|
534
|
-
.tool-indicator.read { background: var(--accent); }
|
|
535
|
-
.tool-indicator.write { background: var(--signal); }
|
|
536
|
-
.tool-indicator.destructive { background: var(--fault); }
|
|
537
|
-
.tool-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
538
|
-
.tool-badge {
|
|
539
|
-
font-size: 10px;
|
|
540
|
-
padding: 2px 6px;
|
|
541
|
-
border-radius: 4px;
|
|
542
|
-
background: var(--surface-raised);
|
|
543
|
-
color: var(--dim);
|
|
544
|
-
text-transform: uppercase;
|
|
545
|
-
letter-spacing: 0.02em;
|
|
546
|
-
}
|
|
547
|
-
.tool-badge.disabled { color: var(--signal); }
|
|
548
|
-
|
|
549
|
-
/* Main content */
|
|
550
|
-
.detail {
|
|
551
|
-
max-width: 640px;
|
|
552
|
-
margin: 0 auto;
|
|
553
|
-
padding: 32px 40px;
|
|
554
|
-
}
|
|
555
|
-
.placeholder {
|
|
556
|
-
display: flex;
|
|
557
|
-
flex-direction: column;
|
|
558
|
-
align-items: center;
|
|
559
|
-
justify-content: center;
|
|
560
|
-
height: 100%;
|
|
561
|
-
color: var(--faint);
|
|
562
|
-
text-align: center;
|
|
563
|
-
padding: 40px;
|
|
564
|
-
}
|
|
565
|
-
.placeholder-icon {
|
|
566
|
-
width: 48px;
|
|
567
|
-
height: 48px;
|
|
568
|
-
border-radius: 12px;
|
|
569
|
-
background: var(--surface-raised);
|
|
570
|
-
display: flex;
|
|
571
|
-
align-items: center;
|
|
572
|
-
justify-content: center;
|
|
573
|
-
margin-bottom: 16px;
|
|
574
|
-
color: var(--ghost);
|
|
575
|
-
}
|
|
576
|
-
.placeholder kbd {
|
|
577
|
-
background: var(--surface-raised);
|
|
578
|
-
padding: 2px 6px;
|
|
579
|
-
border-radius: 4px;
|
|
580
|
-
font-family: var(--mono);
|
|
581
|
-
font-size: 12px;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/* Detail header */
|
|
585
|
-
.detail-header {
|
|
586
|
-
margin-bottom: 24px;
|
|
587
|
-
padding-bottom: 20px;
|
|
588
|
-
border-bottom: 1px solid var(--line-subtle);
|
|
589
|
-
}
|
|
590
|
-
.detail-crumb {
|
|
591
|
-
font-size: 12px;
|
|
592
|
-
color: var(--faint);
|
|
593
|
-
margin-bottom: 8px;
|
|
594
|
-
font-family: var(--mono);
|
|
595
|
-
}
|
|
596
|
-
.detail-title {
|
|
597
|
-
font-size: 24px;
|
|
598
|
-
font-weight: 600;
|
|
599
|
-
margin: 0 0 8px;
|
|
600
|
-
font-family: var(--mono);
|
|
601
|
-
}
|
|
602
|
-
.detail-route {
|
|
603
|
-
font-family: var(--mono);
|
|
604
|
-
font-size: 13px;
|
|
605
|
-
color: var(--dim);
|
|
606
|
-
display: flex;
|
|
607
|
-
align-items: center;
|
|
608
|
-
gap: 8px;
|
|
609
|
-
}
|
|
610
|
-
.verb {
|
|
611
|
-
font-weight: 600;
|
|
612
|
-
padding: 2px 6px;
|
|
613
|
-
border-radius: 4px;
|
|
614
|
-
font-size: 11px;
|
|
615
|
-
}
|
|
616
|
-
.verb.read { color: var(--accent); background: var(--accent-dim); }
|
|
617
|
-
.verb.write { color: var(--signal); background: var(--signal-dim); }
|
|
618
|
-
.verb.destructive { color: var(--fault); background: var(--fault-dim); }
|
|
619
|
-
|
|
620
|
-
/* Badges */
|
|
621
|
-
.badges {
|
|
622
|
-
display: flex;
|
|
623
|
-
gap: 8px;
|
|
624
|
-
margin-top: 12px;
|
|
625
|
-
flex-wrap: wrap;
|
|
626
|
-
}
|
|
627
|
-
.badge {
|
|
628
|
-
font-size: 11px;
|
|
629
|
-
padding: 3px 8px;
|
|
630
|
-
border-radius: 4px;
|
|
631
|
-
font-weight: 500;
|
|
632
|
-
}
|
|
633
|
-
.badge.read { color: var(--accent); background: var(--accent-dim); }
|
|
634
|
-
.badge.write { color: var(--signal); background: var(--signal-dim); }
|
|
635
|
-
.badge.destructive { color: var(--fault); background: var(--fault-dim); }
|
|
636
|
-
.badge.disabled { color: var(--signal); background: var(--signal-dim); }
|
|
637
|
-
.badge.auth { color: var(--fault); background: var(--fault-dim); }
|
|
638
|
-
|
|
639
|
-
/* Sections */
|
|
640
|
-
.section {
|
|
641
|
-
margin-bottom: 28px;
|
|
642
|
-
}
|
|
643
|
-
.section-label {
|
|
644
|
-
font-size: 11px;
|
|
645
|
-
font-weight: 600;
|
|
646
|
-
text-transform: uppercase;
|
|
647
|
-
letter-spacing: 0.05em;
|
|
648
|
-
color: var(--faint);
|
|
649
|
-
margin-bottom: 10px;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/* Description edit */
|
|
653
|
-
.description-edit {
|
|
654
|
-
width: 100%;
|
|
655
|
-
background: var(--surface);
|
|
656
|
-
border: 1px solid var(--line);
|
|
657
|
-
border-radius: 6px;
|
|
658
|
-
padding: 12px;
|
|
659
|
-
color: var(--ink);
|
|
660
|
-
font-size: 14px;
|
|
661
|
-
font-family: inherit;
|
|
662
|
-
line-height: 1.5;
|
|
663
|
-
resize: vertical;
|
|
664
|
-
min-height: 80px;
|
|
665
|
-
}
|
|
666
|
-
.description-edit:focus {
|
|
667
|
-
outline: none;
|
|
668
|
-
border-color: var(--accent);
|
|
669
|
-
}
|
|
670
|
-
.edit-actions {
|
|
671
|
-
display: flex;
|
|
672
|
-
align-items: center;
|
|
673
|
-
gap: 12px;
|
|
674
|
-
margin-top: 10px;
|
|
675
|
-
}
|
|
676
|
-
.btn {
|
|
677
|
-
padding: 8px 16px;
|
|
678
|
-
border-radius: 6px;
|
|
679
|
-
font-size: 13px;
|
|
680
|
-
font-weight: 500;
|
|
681
|
-
cursor: pointer;
|
|
682
|
-
transition: all 0.15s;
|
|
683
|
-
border: 1px solid var(--line);
|
|
684
|
-
background: var(--surface);
|
|
685
|
-
color: var(--ink);
|
|
686
|
-
}
|
|
687
|
-
.btn:hover { background: var(--surface-raised); border-color: var(--ghost); }
|
|
688
|
-
.btn-primary {
|
|
689
|
-
background: var(--accent);
|
|
690
|
-
border-color: var(--accent);
|
|
691
|
-
color: var(--baseline);
|
|
692
|
-
}
|
|
693
|
-
.btn-primary:hover { background: #4a95ee; border-color: #4a95ee; }
|
|
694
|
-
.saved-indicator {
|
|
695
|
-
font-size: 12px;
|
|
696
|
-
color: var(--accent);
|
|
697
|
-
opacity: 0;
|
|
698
|
-
transition: opacity 0.2s;
|
|
699
|
-
}
|
|
700
|
-
.saved-indicator.show { opacity: 1; }
|
|
701
|
-
.edit-hint {
|
|
702
|
-
font-size: 12px;
|
|
703
|
-
color: var(--faint);
|
|
704
|
-
margin-top: 8px;
|
|
705
|
-
line-height: 1.5;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/* Toggle */
|
|
709
|
-
.toggle-row {
|
|
710
|
-
display: flex;
|
|
711
|
-
align-items: center;
|
|
712
|
-
gap: 12px;
|
|
713
|
-
padding: 14px;
|
|
714
|
-
background: var(--surface);
|
|
715
|
-
border: 1px solid var(--line);
|
|
716
|
-
border-radius: 8px;
|
|
717
|
-
}
|
|
718
|
-
.switch {
|
|
719
|
-
width: 40px;
|
|
720
|
-
height: 22px;
|
|
721
|
-
border-radius: 11px;
|
|
722
|
-
background: var(--surface-raised);
|
|
723
|
-
border: 1px solid var(--line);
|
|
724
|
-
position: relative;
|
|
725
|
-
cursor: pointer;
|
|
726
|
-
transition: all 0.2s;
|
|
727
|
-
flex-shrink: 0;
|
|
728
|
-
}
|
|
729
|
-
.switch::after {
|
|
730
|
-
content: "";
|
|
731
|
-
position: absolute;
|
|
732
|
-
width: 16px;
|
|
733
|
-
height: 16px;
|
|
734
|
-
border-radius: 50%;
|
|
735
|
-
background: var(--dim);
|
|
736
|
-
top: 2px;
|
|
737
|
-
left: 2px;
|
|
738
|
-
transition: all 0.2s;
|
|
739
|
-
}
|
|
740
|
-
.switch[aria-checked="true"] {
|
|
741
|
-
background: var(--accent);
|
|
742
|
-
border-color: var(--accent);
|
|
743
|
-
}
|
|
744
|
-
.switch[aria-checked="true"]::after {
|
|
745
|
-
left: 20px;
|
|
746
|
-
background: white;
|
|
747
|
-
}
|
|
748
|
-
.toggle-copy { font-size: 13px; line-height: 1.5; }
|
|
749
|
-
.toggle-copy strong { display: block; margin-bottom: 2px; }
|
|
750
|
-
|
|
751
|
-
/* Try it */
|
|
752
|
-
.try-section {
|
|
753
|
-
background: var(--surface);
|
|
754
|
-
border: 1px solid var(--line);
|
|
755
|
-
border-radius: 8px;
|
|
756
|
-
overflow: hidden;
|
|
757
|
-
}
|
|
758
|
-
.try-header {
|
|
759
|
-
padding: 14px 16px;
|
|
760
|
-
border-bottom: 1px solid var(--line-subtle);
|
|
761
|
-
display: flex;
|
|
762
|
-
align-items: center;
|
|
763
|
-
justify-content: space-between;
|
|
764
|
-
}
|
|
765
|
-
.try-header h3 {
|
|
766
|
-
margin: 0;
|
|
767
|
-
font-size: 13px;
|
|
768
|
-
font-weight: 600;
|
|
769
|
-
}
|
|
770
|
-
.try-note {
|
|
771
|
-
font-size: 11px;
|
|
772
|
-
color: var(--faint);
|
|
773
|
-
}
|
|
774
|
-
.try-body { padding: 16px; }
|
|
775
|
-
.auth-note {
|
|
776
|
-
background: var(--signal-dim);
|
|
777
|
-
border: 1px solid var(--signal);
|
|
778
|
-
color: var(--signal);
|
|
779
|
-
padding: 10px 12px;
|
|
780
|
-
border-radius: 6px;
|
|
781
|
-
font-size: 12px;
|
|
782
|
-
margin-bottom: 14px;
|
|
783
|
-
line-height: 1.5;
|
|
784
|
-
}
|
|
785
|
-
.base-url-input {
|
|
786
|
-
width: 100%;
|
|
787
|
-
background: var(--baseline);
|
|
788
|
-
border: 1px solid var(--line);
|
|
789
|
-
border-radius: 6px;
|
|
790
|
-
padding: 8px 12px;
|
|
791
|
-
color: var(--ink);
|
|
792
|
-
font-size: 13px;
|
|
793
|
-
font-family: var(--mono);
|
|
794
|
-
margin-bottom: 14px;
|
|
795
|
-
}
|
|
796
|
-
.base-url-input:focus {
|
|
797
|
-
outline: none;
|
|
798
|
-
border-color: var(--accent);
|
|
799
|
-
}
|
|
800
|
-
.param-list { margin-bottom: 14px; }
|
|
801
|
-
.param {
|
|
802
|
-
margin-bottom: 12px;
|
|
803
|
-
}
|
|
804
|
-
.param-label {
|
|
805
|
-
display: block;
|
|
806
|
-
font-size: 12px;
|
|
807
|
-
font-weight: 500;
|
|
808
|
-
margin-bottom: 4px;
|
|
809
|
-
color: var(--dim);
|
|
810
|
-
}
|
|
811
|
-
.param-label .req { color: var(--fault); }
|
|
812
|
-
.param-hint {
|
|
813
|
-
font-size: 11px;
|
|
814
|
-
color: var(--faint);
|
|
815
|
-
margin-top: 2px;
|
|
816
|
-
}
|
|
817
|
-
.param-input {
|
|
818
|
-
width: 100%;
|
|
819
|
-
background: var(--baseline);
|
|
820
|
-
border: 1px solid var(--line);
|
|
821
|
-
border-radius: 6px;
|
|
822
|
-
padding: 8px 12px;
|
|
823
|
-
color: var(--ink);
|
|
824
|
-
font-size: 13px;
|
|
825
|
-
font-family: var(--mono);
|
|
826
|
-
}
|
|
827
|
-
.param-input:focus {
|
|
828
|
-
outline: none;
|
|
829
|
-
border-color: var(--accent);
|
|
830
|
-
}
|
|
831
|
-
.run-btn {
|
|
832
|
-
width: 100%;
|
|
833
|
-
padding: 10px;
|
|
834
|
-
background: var(--accent);
|
|
835
|
-
border: none;
|
|
836
|
-
border-radius: 6px;
|
|
837
|
-
color: var(--baseline);
|
|
838
|
-
font-size: 13px;
|
|
839
|
-
font-weight: 600;
|
|
840
|
-
cursor: pointer;
|
|
841
|
-
transition: background 0.15s;
|
|
842
|
-
}
|
|
843
|
-
.run-btn:hover { background: #4a95ee; }
|
|
844
|
-
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
845
|
-
.result {
|
|
846
|
-
margin-top: 14px;
|
|
847
|
-
padding: 12px;
|
|
848
|
-
background: var(--baseline);
|
|
849
|
-
border: 1px solid var(--line);
|
|
850
|
-
border-radius: 6px;
|
|
851
|
-
font-family: var(--mono);
|
|
852
|
-
font-size: 12px;
|
|
853
|
-
white-space: pre-wrap;
|
|
854
|
-
word-break: break-all;
|
|
855
|
-
max-height: 300px;
|
|
856
|
-
overflow-y: auto;
|
|
857
|
-
}
|
|
858
|
-
.result.ok { border-color: var(--accent); }
|
|
859
|
-
.result.err { border-color: var(--fault); }
|
|
860
|
-
|
|
861
|
-
/* Findings */
|
|
862
|
-
.findings {
|
|
863
|
-
margin-bottom: 20px;
|
|
864
|
-
}
|
|
865
|
-
.finding {
|
|
866
|
-
display: flex;
|
|
867
|
-
gap: 8px;
|
|
868
|
-
padding: 10px 12px;
|
|
869
|
-
background: var(--surface);
|
|
870
|
-
border: 1px solid var(--line);
|
|
871
|
-
border-radius: 6px;
|
|
872
|
-
margin-bottom: 8px;
|
|
873
|
-
font-size: 13px;
|
|
874
|
-
line-height: 1.5;
|
|
875
|
-
}
|
|
876
|
-
.finding.warning { border-left: 3px solid var(--signal); }
|
|
877
|
-
.finding.error { border-left: 3px solid var(--fault); }
|
|
878
|
-
.finding-icon { flex-shrink: 0; }
|
|
879
|
-
|
|
880
|
-
/* Scrollbar */
|
|
881
|
-
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
882
|
-
::-webkit-scrollbar-track { background: transparent; }
|
|
883
|
-
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
|
|
884
|
-
::-webkit-scrollbar-thumb:hover { background: var(--ghost); }
|
|
885
|
-
</style>
|
|
886
|
-
</head>
|
|
887
|
-
<body>
|
|
888
|
-
<div class="app">
|
|
889
|
-
<aside class="sidebar">
|
|
890
|
-
<div class="sidebar-header">
|
|
891
|
-
<div class="brand">
|
|
892
|
-
<div class="brand-mark">W</div>
|
|
893
|
-
<span>webmcp-codegen</span>
|
|
894
|
-
</div>
|
|
895
|
-
<div class="brand-sub" id="tool-count"></div>
|
|
896
|
-
</div>
|
|
897
|
-
<div class="search-wrap">
|
|
898
|
-
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
899
|
-
<circle cx="11" cy="11" r="8"></circle>
|
|
900
|
-
<path d="m21 21-4.35-4.35"></path>
|
|
901
|
-
</svg>
|
|
902
|
-
<input type="text" class="search" id="search" placeholder="Search tools..." spellcheck="false" />
|
|
903
|
-
</div>
|
|
904
|
-
<div class="tool-list" id="tool-list"></div>
|
|
905
|
-
</aside>
|
|
906
|
-
<main class="main" id="main">
|
|
907
|
-
<div class="placeholder" id="placeholder">
|
|
908
|
-
<div class="placeholder-icon">
|
|
909
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
910
|
-
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
911
|
-
</svg>
|
|
912
|
-
</div>
|
|
913
|
-
<p>Select a tool to view details</p>
|
|
914
|
-
<p style="font-size: 12px; margin-top: 8px;">
|
|
915
|
-
<kbd>\u2191</kbd> <kbd>\u2193</kbd> to navigate \xB7 <kbd>\u2318K</kbd> to search
|
|
916
|
-
</p>
|
|
917
|
-
</div>
|
|
918
|
-
<div class="detail" id="detail" hidden></div>
|
|
919
|
-
</main>
|
|
920
|
-
</div>
|
|
921
|
-
|
|
922
|
-
<script>
|
|
923
|
-
(function () {
|
|
924
|
-
var state = null;
|
|
925
|
-
var selected = null;
|
|
926
|
-
var filter = "";
|
|
927
|
-
|
|
928
|
-
var listEl = document.getElementById("tool-list");
|
|
929
|
-
var detailEl = document.getElementById("detail");
|
|
930
|
-
var placeholderEl = document.getElementById("placeholder");
|
|
931
|
-
var searchEl = document.getElementById("search");
|
|
932
|
-
var countEl = document.getElementById("tool-count");
|
|
933
|
-
|
|
934
|
-
function esc(text) {
|
|
935
|
-
var div = document.createElement("div");
|
|
936
|
-
div.textContent = text == null ? "" : String(text);
|
|
937
|
-
return div.innerHTML;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function api(path, options) {
|
|
941
|
-
return fetch(path, options).then(function (res) {
|
|
942
|
-
if (!res.ok) throw new Error("Request failed: " + res.status);
|
|
943
|
-
return res.json();
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function load() {
|
|
948
|
-
api("/api/state").then(function (data) {
|
|
949
|
-
state = data;
|
|
950
|
-
countEl.textContent = data.tools.length + " tools from " + data.label;
|
|
951
|
-
renderList();
|
|
952
|
-
renderDetail();
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function visibleTools() {
|
|
957
|
-
if (!state) return [];
|
|
958
|
-
var f = filter.toLowerCase();
|
|
959
|
-
return state.tools.filter(function (tool) {
|
|
960
|
-
return tool.name.toLowerCase().indexOf(f) !== -1 ||
|
|
961
|
-
(tool.description && tool.description.toLowerCase().indexOf(f) !== -1);
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function groupTools(tools) {
|
|
966
|
-
var groups = { read: [], write: [], destructive: [] };
|
|
967
|
-
tools.forEach(function (tool) {
|
|
968
|
-
var key = tool.sideEffect || "read";
|
|
969
|
-
if (!groups[key]) groups[key] = [];
|
|
970
|
-
groups[key].push(tool);
|
|
971
|
-
});
|
|
972
|
-
return groups;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
function renderList() {
|
|
976
|
-
var tools = visibleTools();
|
|
977
|
-
var groups = groupTools(tools);
|
|
978
|
-
var html = "";
|
|
979
|
-
|
|
980
|
-
["read", "write", "destructive"].forEach(function (risk) {
|
|
981
|
-
var group = groups[risk];
|
|
982
|
-
if (!group || group.length === 0) return;
|
|
983
|
-
html += '<div class="tool-group">' + risk + ' (' + group.length + ')</div>';
|
|
984
|
-
group.forEach(function (tool) {
|
|
985
|
-
var isSelected = tool.name === selected;
|
|
986
|
-
html += '<button class="tool" data-name="' + esc(tool.name) + '" aria-selected="' + isSelected + '">' +
|
|
987
|
-
'<span class="tool-indicator ' + risk + '"></span>' +
|
|
988
|
-
'<span class="tool-name">' + esc(tool.name) + "</span>" +
|
|
989
|
-
(!tool.enabled ? '<span class="tool-badge disabled">off</span>' : "") +
|
|
990
|
-
"</button>";
|
|
991
|
-
});
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
if (tools.length === 0) {
|
|
995
|
-
html = '<div style="padding: 20px; text-align: center; color: var(--faint);">No tools match your search</div>';
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
listEl.innerHTML = html;
|
|
999
|
-
|
|
1000
|
-
Array.prototype.forEach.call(listEl.querySelectorAll(".tool"), function (btn) {
|
|
1001
|
-
btn.addEventListener("click", function () {
|
|
1002
|
-
selected = btn.getAttribute("data-name");
|
|
1003
|
-
renderList();
|
|
1004
|
-
renderDetail();
|
|
1005
|
-
});
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function currentTool() {
|
|
1010
|
-
if (!state || !selected) return null;
|
|
1011
|
-
return state.tools.find(function (tool) { return tool.name === selected; });
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function renderDetail() {
|
|
1015
|
-
var tool = currentTool();
|
|
1016
|
-
if (!tool) {
|
|
1017
|
-
detailEl.hidden = true;
|
|
1018
|
-
placeholderEl.hidden = false;
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
placeholderEl.hidden = true;
|
|
1023
|
-
detailEl.hidden = false;
|
|
1024
|
-
|
|
1025
|
-
var badges = [
|
|
1026
|
-
'<span class="badge ' + tool.sideEffect + '">' + tool.sideEffect + "</span>",
|
|
1027
|
-
!tool.enabled ? '<span class="badge disabled">starts disabled</span>' : "",
|
|
1028
|
-
tool.endpointRole !== "endpoint" ? '<span class="badge auth">' + tool.endpointRole + "</span>" : "",
|
|
1029
|
-
tool.piiInOutput.length > 0 ? '<span class="badge write">pii: ' + esc(tool.piiInOutput.join(", ")) + "</span>" : "",
|
|
1030
|
-
].filter(Boolean).join("");
|
|
1031
|
-
|
|
1032
|
-
var findings = tool.findings.map(function (finding) {
|
|
1033
|
-
var icon = finding.level === "error" ? "\u2716" : "\u26A0";
|
|
1034
|
-
return '<div class="finding ' + finding.level + '"><span class="finding-icon">' + icon + "</span><span>" + esc(finding.message) + "</span></div>";
|
|
1035
|
-
}).join("");
|
|
1036
|
-
|
|
1037
|
-
var schema = tool.inputSchema || {};
|
|
1038
|
-
var properties = schema.properties || {};
|
|
1039
|
-
var required = schema.required || [];
|
|
1040
|
-
var fields = Object.keys(properties).map(function (key) {
|
|
1041
|
-
var field = properties[key];
|
|
1042
|
-
var type = field.type === "number" || field.type === "integer" ? "number" : "text";
|
|
1043
|
-
var req = required.indexOf(key) !== -1 ? ' <span class="req">*</span>' : "";
|
|
1044
|
-
var hint = field.description ? '<div class="param-hint">' + esc(field.description) + "</div>" : "";
|
|
1045
|
-
return '<div class="param"><label class="param-label">' + esc(key) + req + '</label>' +
|
|
1046
|
-
'<input class="param-input" data-field="' + esc(key) + '" data-type="' + esc(field.type || "string") + '" type="' + type + '" spellcheck="false" />' +
|
|
1047
|
-
hint + "</div>";
|
|
1048
|
-
}).join("");
|
|
1049
|
-
|
|
1050
|
-
var baseUrl = "";
|
|
1051
|
-
try { baseUrl = localStorage.getItem("webmcp-codegen:baseUrl") || tool.serverUrl || ""; } catch (e) {}
|
|
1052
|
-
|
|
1053
|
-
detailEl.innerHTML =
|
|
1054
|
-
'<div class="detail-header">' +
|
|
1055
|
-
'<div class="detail-crumb">' + esc(state.label) + (state.outDir ? " \u2192 " + esc(state.outDir) : "") + "</div>" +
|
|
1056
|
-
'<h1 class="detail-title">' + esc(tool.name) + "</h1>" +
|
|
1057
|
-
'<div class="detail-route">' +
|
|
1058
|
-
'<span class="verb ' + tool.sideEffect + '">' + esc(tool.verb || "GET") + "</span>" +
|
|
1059
|
-
"<span>" + esc(tool.path || "") + "</span>" +
|
|
1060
|
-
"</div>" +
|
|
1061
|
-
'<div class="badges">' + badges + "</div>" +
|
|
1062
|
-
"</div>" +
|
|
1063
|
-
|
|
1064
|
-
(findings ? '<div class="section"><div class="section-label">Audit findings</div>' + findings + "</div>" : "") +
|
|
1065
|
-
|
|
1066
|
-
'<div class="section">' +
|
|
1067
|
-
'<div class="section-label">Description</div>' +
|
|
1068
|
-
'<textarea class="description-edit" id="desc" spellcheck="false">' + esc(tool.description) + "</textarea>" +
|
|
1069
|
-
'<div class="edit-actions">' +
|
|
1070
|
-
'<button class="btn btn-primary" id="save-desc">Save</button>' +
|
|
1071
|
-
'<span class="saved-indicator" id="saved">Saved</span>' +
|
|
1072
|
-
"</div>" +
|
|
1073
|
-
'<div class="edit-hint">Agents pick tools by this text. Saved to .webmcp-codegen.json, so it survives regeneration. \u2318S to save.</div>' +
|
|
1074
|
-
"</div>" +
|
|
1075
|
-
|
|
1076
|
-
'<div class="section">' +
|
|
1077
|
-
'<div class="section-label">Status</div>' +
|
|
1078
|
-
'<div class="toggle-row">' +
|
|
1079
|
-
'<button class="switch" id="toggle-enabled" role="switch" aria-checked="' + tool.enabled + '" aria-label="Enabled"></button>' +
|
|
1080
|
-
'<div class="toggle-copy"><strong>' + (tool.enabled ? "Enabled" : "Disabled") + "</strong>" +
|
|
1081
|
-
(tool.enabled
|
|
1082
|
-
? "This tool works as soon as the app registers it."
|
|
1083
|
-
: "The generated code is there, commented out. Flipping this regenerates it enabled on the next run.") +
|
|
1084
|
-
"</div></div>" +
|
|
1085
|
-
"</div>" +
|
|
1086
|
-
|
|
1087
|
-
'<div class="section">' +
|
|
1088
|
-
'<div class="section-label">Test</div>' +
|
|
1089
|
-
'<div class="try-section">' +
|
|
1090
|
-
'<div class="try-header"><h3>Run this tool</h3><span class="try-note">server-side, no browser session</span></div>' +
|
|
1091
|
-
'<div class="try-body">' +
|
|
1092
|
-
(tool.requiresAuth
|
|
1093
|
-
? '<div class="auth-note">\u26A0 This endpoint requires a browser session. The dashboard runs server-side, so you will get a 401. Test it in Chrome DevTools where you are signed in.</div>'
|
|
1094
|
-
: "") +
|
|
1095
|
-
'<input class="base-url-input" id="base-url" type="text" placeholder="Base URL (e.g. http://localhost:3000)" value="' + esc(baseUrl) + '" spellcheck="false" />' +
|
|
1096
|
-
(fields || '<div style="color: var(--faint); font-size: 13px; margin-bottom: 14px;">This tool takes no inputs.</div>') +
|
|
1097
|
-
'<button class="run-btn" id="run">Run tool</button>' +
|
|
1098
|
-
'<pre class="result" id="result" hidden></pre>' +
|
|
1099
|
-
"</div></div>" +
|
|
1100
|
-
"</div>";
|
|
1101
|
-
|
|
1102
|
-
document.getElementById("save-desc").addEventListener("click", saveDescription);
|
|
1103
|
-
document.getElementById("toggle-enabled").addEventListener("click", toggleEnabled);
|
|
1104
|
-
document.getElementById("run").addEventListener("click", runTool);
|
|
1105
|
-
document.getElementById("base-url").addEventListener("change", function (event) {
|
|
1106
|
-
try { localStorage.setItem("webmcp-codegen:baseUrl", event.target.value); } catch (e) {}
|
|
1107
|
-
});
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
function saveDescription() {
|
|
1111
|
-
var tool = currentTool();
|
|
1112
|
-
var desc = document.getElementById("desc").value.trim();
|
|
1113
|
-
if (!tool || !desc) return;
|
|
1114
|
-
api("/api/override", {
|
|
1115
|
-
method: "POST",
|
|
1116
|
-
headers: { "content-type": "application/json" },
|
|
1117
|
-
body: JSON.stringify({ name: tool.name, description: desc }),
|
|
1118
|
-
}).then(function () {
|
|
1119
|
-
tool.description = desc;
|
|
1120
|
-
var saved = document.getElementById("saved");
|
|
1121
|
-
saved.classList.add("show");
|
|
1122
|
-
setTimeout(function () { saved.classList.remove("show"); }, 2000);
|
|
1123
|
-
}).catch(function (error) { alert(error.message); });
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
function toggleEnabled() {
|
|
1127
|
-
var tool = currentTool();
|
|
1128
|
-
if (!tool) return;
|
|
1129
|
-
var next = !tool.enabled;
|
|
1130
|
-
api("/api/override", {
|
|
1131
|
-
method: "POST",
|
|
1132
|
-
headers: { "content-type": "application/json" },
|
|
1133
|
-
body: JSON.stringify({ name: tool.name, enabled: next }),
|
|
1134
|
-
}).then(function () {
|
|
1135
|
-
tool.enabled = next;
|
|
1136
|
-
renderList();
|
|
1137
|
-
renderDetail();
|
|
1138
|
-
}).catch(function (error) { alert(error.message); });
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
function runTool() {
|
|
1142
|
-
var tool = currentTool();
|
|
1143
|
-
if (!tool) return;
|
|
1144
|
-
var input = {};
|
|
1145
|
-
Array.prototype.forEach.call(document.querySelectorAll("[data-field]"), function (field) {
|
|
1146
|
-
var value = field.value;
|
|
1147
|
-
if (value === "") return;
|
|
1148
|
-
var type = field.getAttribute("data-type");
|
|
1149
|
-
if (type === "number" || type === "integer") value = Number(value);
|
|
1150
|
-
if (type === "boolean") value = value === "true";
|
|
1151
|
-
if (type === "object" || type === "array") {
|
|
1152
|
-
try { value = JSON.parse(value); } catch (e) { /* keep as string */ }
|
|
1153
|
-
}
|
|
1154
|
-
input[field.getAttribute("data-field")] = value;
|
|
1155
|
-
});
|
|
1156
|
-
var baseUrl = document.getElementById("base-url").value.trim();
|
|
1157
|
-
var resultEl = document.getElementById("result");
|
|
1158
|
-
var runEl = document.getElementById("run");
|
|
1159
|
-
runEl.disabled = true;
|
|
1160
|
-
runEl.textContent = "Running...";
|
|
1161
|
-
resultEl.hidden = true;
|
|
1162
|
-
api("/api/run", {
|
|
1163
|
-
method: "POST",
|
|
1164
|
-
headers: { "content-type": "application/json" },
|
|
1165
|
-
body: JSON.stringify({ name: tool.name, input: input, baseUrl: baseUrl || undefined }),
|
|
1166
|
-
}).then(function (result) {
|
|
1167
|
-
resultEl.hidden = false;
|
|
1168
|
-
resultEl.className = "result " + (result.ok ? "ok" : "err");
|
|
1169
|
-
resultEl.textContent =
|
|
1170
|
-
(result.status ? "HTTP " + result.status + "
|
|
1171
|
-
|
|
1172
|
-
" : "") +
|
|
1173
|
-
(result.error ? result.error : JSON.stringify(result.body, null, 2));
|
|
1174
|
-
}).catch(function (error) {
|
|
1175
|
-
resultEl.hidden = false;
|
|
1176
|
-
resultEl.className = "result err";
|
|
1177
|
-
resultEl.textContent = error.message;
|
|
1178
|
-
}).finally(function () {
|
|
1179
|
-
runEl.disabled = false;
|
|
1180
|
-
runEl.textContent = "Run tool";
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/* Keyboard navigation */
|
|
1185
|
-
document.addEventListener("keydown", function (event) {
|
|
1186
|
-
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
|
1187
|
-
event.preventDefault();
|
|
1188
|
-
searchEl.focus();
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
|
1192
|
-
event.preventDefault();
|
|
1193
|
-
saveDescription();
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
if (event.target === searchEl || event.target.tagName === "TEXTAREA" || event.target.tagName === "INPUT") {
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
1200
|
-
var tools = visibleTools();
|
|
1201
|
-
var index = tools.findIndex(function (tool) { return tool.name === selected; });
|
|
1202
|
-
var next = event.key === "ArrowDown" ? index + 1 : index - 1;
|
|
1203
|
-
if (next < 0 || next >= tools.length) return;
|
|
1204
|
-
event.preventDefault();
|
|
1205
|
-
selected = tools[next].name;
|
|
1206
|
-
renderList();
|
|
1207
|
-
renderDetail();
|
|
1208
|
-
var button = listEl.querySelector('[aria-selected="true"]');
|
|
1209
|
-
if (button) button.scrollIntoView({ block: "nearest" });
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
searchEl.addEventListener("input", function (event) {
|
|
1213
|
-
filter = event.target.value;
|
|
1214
|
-
renderList();
|
|
1215
|
-
});
|
|
1216
|
-
|
|
1217
|
-
load();
|
|
1218
|
-
})();
|
|
1219
|
-
</script>
|
|
1220
|
-
</body>
|
|
1221
|
-
</html>`;
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
// src/dev/server.ts
|
|
1225
|
-
async function startDevServer(options) {
|
|
1226
|
-
const setup = await resolveSetup(options.cwd, {
|
|
1227
|
-
dryRun: true,
|
|
1228
|
-
skipAudit: false,
|
|
1229
|
-
force: false,
|
|
1230
|
-
watch: false
|
|
1231
|
-
});
|
|
1232
|
-
async function currentState() {
|
|
1233
|
-
const data = await loadDataFile(options.cwd);
|
|
1234
|
-
const result = await runGenerate(setup.config, {
|
|
1235
|
-
cwd: options.cwd,
|
|
1236
|
-
dryRun: true,
|
|
1237
|
-
overrides: data.overrides
|
|
1238
|
-
});
|
|
1239
|
-
return {
|
|
1240
|
-
label: setup.label,
|
|
1241
|
-
outDir: setup.config.generate[0]?.outDir,
|
|
1242
|
-
tools: result.tools.map((tool) => toUiTool(tool, result.findings)),
|
|
1243
|
-
skipped: result.skipped,
|
|
1244
|
-
notes: result.notes
|
|
1245
|
-
};
|
|
1246
|
-
}
|
|
1247
|
-
const server = createServer(async (request, response) => {
|
|
1248
|
-
try {
|
|
1249
|
-
await route(request, response);
|
|
1250
|
-
} catch (error) {
|
|
1251
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1252
|
-
}
|
|
1253
|
-
});
|
|
1254
|
-
async function route(request, response) {
|
|
1255
|
-
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1256
|
-
if (request.method === "GET" && url.pathname === "/") {
|
|
1257
|
-
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
1258
|
-
response.end(dashboardHtml());
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
1262
|
-
sendJson(response, 200, await currentState());
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
if (request.method === "POST" && url.pathname === "/api/override") {
|
|
1266
|
-
const body = await readJson(request);
|
|
1267
|
-
if (!body.name) {
|
|
1268
|
-
sendJson(response, 400, { error: "Missing tool name." });
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
const data = await loadDataFile(options.cwd);
|
|
1272
|
-
const overrides = { ...data.overrides ?? {} };
|
|
1273
|
-
const existing = overrides[body.name] ?? {};
|
|
1274
|
-
overrides[body.name] = {
|
|
1275
|
-
...existing,
|
|
1276
|
-
...body.description !== void 0 ? { description: body.description } : {},
|
|
1277
|
-
...body.enabled !== void 0 ? { enabled: body.enabled } : {}
|
|
1278
|
-
};
|
|
1279
|
-
await saveDataFile(options.cwd, { overrides });
|
|
1280
|
-
sendJson(response, 200, { ok: true, saved: `.webmcp-codegen.json` });
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
if (request.method === "POST" && url.pathname === "/api/run") {
|
|
1284
|
-
const body = await readJson(request);
|
|
1285
|
-
const state = await currentState();
|
|
1286
|
-
const tool = state.tools.find((candidate) => candidate.name === body.name);
|
|
1287
|
-
if (!tool) {
|
|
1288
|
-
sendJson(response, 404, { error: `No tool named "${body.name}".` });
|
|
1289
|
-
return;
|
|
1290
|
-
}
|
|
1291
|
-
const result = await runEndpoint(tool, body.input ?? {}, body.baseUrl);
|
|
1292
|
-
sendJson(response, result.ok ? 200 : 502, result);
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
sendJson(response, 404, { error: "Not found" });
|
|
1296
|
-
}
|
|
1297
|
-
await new Promise(
|
|
1298
|
-
(resolveListen) => server.listen(options.port, "127.0.0.1", resolveListen)
|
|
1299
|
-
);
|
|
1300
|
-
if (options.open !== false) openBrowser(`http://localhost:${options.port}`);
|
|
1301
|
-
return server;
|
|
1302
|
-
}
|
|
1303
|
-
function toUiTool(tool, findings) {
|
|
1304
|
-
const [verb, ...rest] = tool.source.ref.split(" ");
|
|
1305
|
-
return {
|
|
1306
|
-
name: tool.name,
|
|
1307
|
-
verb,
|
|
1308
|
-
path: rest.join(" "),
|
|
1309
|
-
description: tool.description,
|
|
1310
|
-
sideEffect: tool.sideEffect,
|
|
1311
|
-
riskTier: tool.riskTier,
|
|
1312
|
-
enabled: tool.enabledByDefault,
|
|
1313
|
-
endpointRole: tool.endpointRole,
|
|
1314
|
-
piiInOutput: tool.piiInOutput,
|
|
1315
|
-
inputSchema: tool.inputSchema,
|
|
1316
|
-
...tool.pathTemplate ? { pathTemplate: tool.pathTemplate } : {},
|
|
1317
|
-
...tool.paramLocations ? { paramLocations: tool.paramLocations } : {},
|
|
1318
|
-
...tool.serverUrl ? { serverUrl: tool.serverUrl } : {},
|
|
1319
|
-
requiresAuth: tool.requiresAuth,
|
|
1320
|
-
findings: findings.filter((finding) => finding.tool === tool.name).map((finding) => ({ level: finding.level, message: finding.message }))
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
async function runEndpoint(tool, input, baseUrlOverride) {
|
|
1324
|
-
const base = baseUrlOverride ?? tool.serverUrl;
|
|
1325
|
-
if (!base) {
|
|
1326
|
-
return {
|
|
1327
|
-
ok: false,
|
|
1328
|
-
error: `No base URL: the spec lists no absolute server. Type your app's URL (e.g. http://localhost:3000) in the "base URL" field and run again.`
|
|
1329
|
-
};
|
|
1330
|
-
}
|
|
1331
|
-
if (!tool.pathTemplate || !tool.verb) {
|
|
1332
|
-
return { ok: false, error: "This tool has no route to call." };
|
|
1333
|
-
}
|
|
1334
|
-
let path = tool.pathTemplate;
|
|
1335
|
-
for (const param of tool.paramLocations?.path ?? []) {
|
|
1336
|
-
path = path.replace(`{${param}}`, encodeURIComponent(String(input[param] ?? "")));
|
|
1337
|
-
}
|
|
1338
|
-
const url = new URL(path, base);
|
|
1339
|
-
for (const param of tool.paramLocations?.query ?? []) {
|
|
1340
|
-
const value = input[param];
|
|
1341
|
-
if (value !== void 0 && value !== null) url.searchParams.set(param, String(value));
|
|
1342
|
-
}
|
|
1343
|
-
const bodyFields = tool.paramLocations?.body ?? [];
|
|
1344
|
-
const body = bodyFields.length === 1 && bodyFields[0] === "body" ? input.body : bodyFields.length > 0 ? Object.fromEntries(bodyFields.map((field) => [field, input[field]])) : void 0;
|
|
1345
|
-
try {
|
|
1346
|
-
const response = await fetch(url, {
|
|
1347
|
-
method: tool.verb,
|
|
1348
|
-
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
1349
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1350
|
-
});
|
|
1351
|
-
const text = await response.text();
|
|
1352
|
-
let parsed = text;
|
|
1353
|
-
try {
|
|
1354
|
-
parsed = JSON.parse(text);
|
|
1355
|
-
} catch {
|
|
1356
|
-
}
|
|
1357
|
-
return { ok: response.ok, status: response.status, body: parsed };
|
|
1358
|
-
} catch (error) {
|
|
1359
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
function sendJson(response, status, body) {
|
|
1363
|
-
response.writeHead(status, { "content-type": "application/json" });
|
|
1364
|
-
response.end(JSON.stringify(body));
|
|
1365
|
-
}
|
|
1366
|
-
function readJson(request) {
|
|
1367
|
-
return new Promise((resolveRead, reject) => {
|
|
1368
|
-
let text = "";
|
|
1369
|
-
request.on("data", (chunk) => {
|
|
1370
|
-
text += chunk.toString("utf8");
|
|
1371
|
-
});
|
|
1372
|
-
request.on("end", () => {
|
|
1373
|
-
try {
|
|
1374
|
-
resolveRead(text ? JSON.parse(text) : {});
|
|
1375
|
-
} catch {
|
|
1376
|
-
reject(new Error("Invalid JSON body"));
|
|
1377
|
-
}
|
|
1378
|
-
});
|
|
1379
|
-
request.on("error", reject);
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
function openBrowser(url) {
|
|
1383
|
-
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1384
|
-
spawn(command, [url], { stdio: "ignore", shell: process.platform === "win32" }).unref();
|
|
153
|
+
console.log(dim(`Files: ${setup.config.generate[0]?.outDir ?? "src/webmcp"}`));
|
|
154
|
+
console.log(dim(`Docs: https://webmcp-codegen.vercel.app/docs`));
|
|
155
|
+
console.log("");
|
|
1385
156
|
}
|
|
1386
157
|
|
|
1387
158
|
// src/wire.ts
|
|
1388
|
-
import { readFile
|
|
1389
|
-
import { dirname, join
|
|
159
|
+
import { readFile, writeFile } from "fs/promises";
|
|
160
|
+
import { dirname, join, relative } from "path";
|
|
1390
161
|
async function planWiring(cwd, app, outDir) {
|
|
1391
162
|
switch (app.framework) {
|
|
1392
163
|
case "next":
|
|
@@ -1399,28 +170,28 @@ async function planWiring(cwd, app, outDir) {
|
|
|
1399
170
|
}
|
|
1400
171
|
async function applyWiring(plan) {
|
|
1401
172
|
for (const edit of plan.edits) {
|
|
1402
|
-
await
|
|
173
|
+
await writeFile(edit.path, edit.contents, "utf8");
|
|
1403
174
|
}
|
|
1404
175
|
}
|
|
1405
176
|
async function planNextWiring(cwd, app, outDir) {
|
|
1406
177
|
const layoutCandidates = [
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
178
|
+
join(cwd, app.dir, "src/app/layout.tsx"),
|
|
179
|
+
join(cwd, app.dir, "src/app/layout.jsx"),
|
|
180
|
+
join(cwd, app.dir, "app/layout.tsx"),
|
|
181
|
+
join(cwd, app.dir, "app/layout.jsx")
|
|
1411
182
|
];
|
|
1412
183
|
const layoutPath = await firstExisting(layoutCandidates);
|
|
1413
184
|
if (!layoutPath) return null;
|
|
1414
|
-
const registerPath =
|
|
1415
|
-
const layout = await
|
|
185
|
+
const registerPath = join(cwd, outDir, "register.tsx");
|
|
186
|
+
const layout = await readFile(layoutPath, "utf8");
|
|
1416
187
|
if (layout.includes("WebMCPRegister")) return { edits: [], alreadyWired: true };
|
|
1417
|
-
const importPath = withoutExtension(
|
|
188
|
+
const importPath = withoutExtension(relative(dirname(layoutPath), registerPath));
|
|
1418
189
|
const edits = [
|
|
1419
190
|
{
|
|
1420
191
|
path: registerPath,
|
|
1421
192
|
action: "create",
|
|
1422
193
|
contents: nextRegisterComponent(),
|
|
1423
|
-
summary: `created ${
|
|
194
|
+
summary: `created ${relative(cwd, registerPath)} (a client component that registers your tools on page load)`
|
|
1424
195
|
}
|
|
1425
196
|
];
|
|
1426
197
|
const withImport = insertAfterLastImport(
|
|
@@ -1437,7 +208,7 @@ async function planNextWiring(cwd, app, outDir) {
|
|
|
1437
208
|
path: layoutPath,
|
|
1438
209
|
action: "modify",
|
|
1439
210
|
contents: withComponent,
|
|
1440
|
-
summary: `added 2 lines to ${
|
|
211
|
+
summary: `added 2 lines to ${relative(cwd, layoutPath)} (an import and <WebMCPRegister /> inside <body>)`
|
|
1441
212
|
});
|
|
1442
213
|
return { edits };
|
|
1443
214
|
}
|
|
@@ -1461,16 +232,16 @@ export function WebMCPRegister() {
|
|
|
1461
232
|
}
|
|
1462
233
|
async function planViteWiring(cwd, app, outDir) {
|
|
1463
234
|
const entryCandidates = [
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
235
|
+
join(cwd, app.dir, "src/main.tsx"),
|
|
236
|
+
join(cwd, app.dir, "src/main.jsx"),
|
|
237
|
+
join(cwd, app.dir, "src/index.tsx"),
|
|
238
|
+
join(cwd, app.dir, "src/index.jsx")
|
|
1468
239
|
];
|
|
1469
240
|
const entryPath = await firstExisting(entryCandidates);
|
|
1470
241
|
if (!entryPath) return null;
|
|
1471
|
-
const entry = await
|
|
242
|
+
const entry = await readFile(entryPath, "utf8");
|
|
1472
243
|
if (entry.includes("registerAllTools")) return { edits: [], alreadyWired: true };
|
|
1473
|
-
const importPath = withoutExtension(
|
|
244
|
+
const importPath = withoutExtension(relative(dirname(entryPath), join(cwd, outDir, "index")));
|
|
1474
245
|
const withWiring = insertAfterLastImport(
|
|
1475
246
|
entry,
|
|
1476
247
|
`import { registerAllTools } from "${importPath}";
|
|
@@ -1484,7 +255,7 @@ void registerAllTools();`
|
|
|
1484
255
|
path: entryPath,
|
|
1485
256
|
action: "modify",
|
|
1486
257
|
contents: withWiring,
|
|
1487
|
-
summary: `added 2 lines to ${
|
|
258
|
+
summary: `added 2 lines to ${relative(cwd, entryPath)} (an import and a registerAllTools() call)`
|
|
1488
259
|
}
|
|
1489
260
|
]
|
|
1490
261
|
};
|
|
@@ -1506,7 +277,7 @@ function withoutExtension(path) {
|
|
|
1506
277
|
async function firstExisting(paths) {
|
|
1507
278
|
for (const path of paths) {
|
|
1508
279
|
try {
|
|
1509
|
-
await
|
|
280
|
+
await readFile(path, "utf8");
|
|
1510
281
|
return path;
|
|
1511
282
|
} catch {
|
|
1512
283
|
}
|
|
@@ -1597,8 +368,8 @@ async function main() {
|
|
|
1597
368
|
async function init() {
|
|
1598
369
|
const cwd = process.cwd();
|
|
1599
370
|
const configFile = CONFIG_FILE_NAMES[0] ?? "codegen.config.mjs";
|
|
1600
|
-
const configPath =
|
|
1601
|
-
if (
|
|
371
|
+
const configPath = join2(cwd, configFile);
|
|
372
|
+
if (existsSync(configPath)) {
|
|
1602
373
|
console.error(`
|
|
1603
374
|
\u2716 ${configFile} already exists. Nothing to do.
|
|
1604
375
|
`);
|
|
@@ -1606,7 +377,7 @@ async function init() {
|
|
|
1606
377
|
}
|
|
1607
378
|
const specs = await findSpecs(cwd);
|
|
1608
379
|
const specPath = specs.length > 0 ? `./${specs[0]}` : "./openapi.yaml";
|
|
1609
|
-
await
|
|
380
|
+
await writeFile2(
|
|
1610
381
|
configPath,
|
|
1611
382
|
`import { defineConfig } from "webmcp-codegen";
|
|
1612
383
|
import { openapi } from "webmcp-codegen/sources";
|