webmcp-codegen 0.2.0 → 0.3.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/README.md +45 -36
- package/dist/{chunk-WYGVTIGI.js → chunk-3LTHWIAP.js} +37 -4
- package/dist/chunk-3LTHWIAP.js.map +1 -0
- package/dist/{chunk-BIKKPCRT.js → chunk-FWSATV7C.js} +26 -2
- package/dist/chunk-FWSATV7C.js.map +1 -0
- package/dist/chunk-JVBVTHZ7.js +235 -0
- package/dist/chunk-JVBVTHZ7.js.map +1 -0
- package/dist/{chunk-5L4KN6F4.js → chunk-KSQMJERY.js} +3 -3
- package/dist/chunk-KSQMJERY.js.map +1 -0
- package/dist/{chunk-R3DEBBQ3.js → chunk-MJQ5B6HB.js} +111 -21
- package/dist/chunk-MJQ5B6HB.js.map +1 -0
- package/dist/chunk-TGOJ3HUE.js +382 -0
- package/dist/chunk-TGOJ3HUE.js.map +1 -0
- package/dist/cli.js +263 -102
- package/dist/cli.js.map +1 -1
- package/dist/generators/index.d.ts +3 -3
- package/dist/generators/index.js +2 -2
- package/dist/index.d.ts +15 -5
- package/dist/index.js +3 -3
- package/dist/server-OR4IMRQ5.js +638 -0
- package/dist/server-OR4IMRQ5.js.map +1 -0
- package/dist/sources/index.d.ts +1 -1
- package/dist/sources/index.js +3 -3
- package/dist/{types-Bf5MxWeH.d.ts → types-DWUum51l.d.ts} +56 -3
- package/package.json +1 -1
- package/dist/chunk-5L4KN6F4.js.map +0 -1
- package/dist/chunk-BIKKPCRT.js.map +0 -1
- package/dist/chunk-GDJDVR4E.js +0 -251
- package/dist/chunk-GDJDVR4E.js.map +0 -1
- package/dist/chunk-R3DEBBQ3.js.map +0 -1
- package/dist/chunk-WYGVTIGI.js.map +0 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
dedupeNames
|
|
3
|
-
|
|
2
|
+
dedupeNames,
|
|
3
|
+
stripVersionPrefix
|
|
4
|
+
} from "./chunk-FWSATV7C.js";
|
|
4
5
|
import {
|
|
5
6
|
pascalCase
|
|
6
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-KSQMJERY.js";
|
|
7
8
|
|
|
8
9
|
// src/config.ts
|
|
9
10
|
import { access } from "fs/promises";
|
|
@@ -50,6 +51,26 @@ import { dirname } from "path";
|
|
|
50
51
|
|
|
51
52
|
// src/safety.ts
|
|
52
53
|
var DESTRUCTIVE_WORDS = /\b(cancel|delete|remove|destroy|deactivate|refund|revoke|purge|close)\b/i;
|
|
54
|
+
var READING_POST_WORDS = /* @__PURE__ */ new Set([
|
|
55
|
+
"search",
|
|
56
|
+
"query",
|
|
57
|
+
"list",
|
|
58
|
+
"find",
|
|
59
|
+
"filter",
|
|
60
|
+
"estimate",
|
|
61
|
+
"preview",
|
|
62
|
+
"validate",
|
|
63
|
+
"check",
|
|
64
|
+
"lookup",
|
|
65
|
+
"autocomplete",
|
|
66
|
+
"suggest"
|
|
67
|
+
]);
|
|
68
|
+
function nameSaysRead(toolName) {
|
|
69
|
+
return toolName.split("-").some((segment) => READING_POST_WORDS.has(segment));
|
|
70
|
+
}
|
|
71
|
+
var WEBHOOK_PATTERN = /\bwebhooks?\b/i;
|
|
72
|
+
var AUTH_PATTERN = /\b(auth|signin|sign-in|login|log-in|logout|log-out|oauth|password|credential|session)s?\b/i;
|
|
73
|
+
var ADMIN_PATTERN = /\badmin\b/i;
|
|
53
74
|
var DEFAULT_PII_FIELDS = [
|
|
54
75
|
"password",
|
|
55
76
|
"ssn",
|
|
@@ -76,6 +97,8 @@ function classifySideEffect(tool) {
|
|
|
76
97
|
case "DELETE":
|
|
77
98
|
return "destructive";
|
|
78
99
|
case "POST":
|
|
100
|
+
if (nameSaysRead(tool.name)) return "read";
|
|
101
|
+
return DESTRUCTIVE_WORDS.test(tool.name) || DESTRUCTIVE_WORDS.test(tool.source.ref) ? "destructive" : "write";
|
|
79
102
|
case "PUT":
|
|
80
103
|
case "PATCH":
|
|
81
104
|
return DESTRUCTIVE_WORDS.test(tool.name) || DESTRUCTIVE_WORDS.test(tool.source.ref) ? "destructive" : "write";
|
|
@@ -83,6 +106,13 @@ function classifySideEffect(tool) {
|
|
|
83
106
|
return "unknown";
|
|
84
107
|
}
|
|
85
108
|
}
|
|
109
|
+
function endpointRoleFor(tool) {
|
|
110
|
+
const haystack = `${tool.name} ${tool.source.ref}`;
|
|
111
|
+
if (WEBHOOK_PATTERN.test(haystack)) return "webhook";
|
|
112
|
+
if (AUTH_PATTERN.test(haystack)) return "auth";
|
|
113
|
+
if (ADMIN_PATTERN.test(haystack)) return "admin";
|
|
114
|
+
return "endpoint";
|
|
115
|
+
}
|
|
86
116
|
function hintsFor(tool, sideEffect) {
|
|
87
117
|
const method = tool.httpMethod;
|
|
88
118
|
return {
|
|
@@ -119,20 +149,39 @@ function findPiiFields(schema, extraFields = [], prefix = "") {
|
|
|
119
149
|
}
|
|
120
150
|
function reviewTools(candidates, safety = {}) {
|
|
121
151
|
const excluded = (safety.exclude ?? []).map((pattern) => pattern.toLowerCase());
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
const skipped = [];
|
|
153
|
+
const tools = [];
|
|
154
|
+
for (const tool of candidates) {
|
|
155
|
+
const excludedBy = excluded.find(
|
|
124
156
|
(pattern) => tool.name.toLowerCase().includes(pattern) || tool.source.ref.toLowerCase().includes(pattern)
|
|
125
|
-
)
|
|
126
|
-
|
|
157
|
+
);
|
|
158
|
+
if (excludedBy) {
|
|
159
|
+
skipped.push({ ref: tool.source.ref, reason: `excluded by config ("${excludedBy}")` });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const endpointRole = endpointRoleFor(tool);
|
|
163
|
+
if (endpointRole === "webhook") {
|
|
164
|
+
skipped.push({
|
|
165
|
+
ref: tool.source.ref,
|
|
166
|
+
reason: "a webhook receives server callbacks; an agent has nothing to call"
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
127
170
|
const sideEffect = classifySideEffect(tool);
|
|
128
|
-
|
|
171
|
+
tools.push({
|
|
129
172
|
...tool,
|
|
130
173
|
sideEffect,
|
|
174
|
+
endpointRole,
|
|
131
175
|
riskTier: riskTierFor(sideEffect),
|
|
132
176
|
hints: hintsFor(tool, sideEffect),
|
|
177
|
+
// Reads work out of the box. Mutations, auth, and admin endpoints start
|
|
178
|
+
// disabled: the working code is generated but commented out, so enabling
|
|
179
|
+
// one is a deliberate edit, never an accident.
|
|
180
|
+
enabledByDefault: sideEffect === "read" && endpointRole === "endpoint",
|
|
133
181
|
piiInOutput: findPiiFields(tool.outputSchema, safety.piiFields)
|
|
134
|
-
};
|
|
135
|
-
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return { tools, skipped };
|
|
136
185
|
}
|
|
137
186
|
function auditTools(tools, renames = []) {
|
|
138
187
|
const findings = [];
|
|
@@ -148,7 +197,7 @@ function auditTools(tools, renames = []) {
|
|
|
148
197
|
findings.push({
|
|
149
198
|
level: "error",
|
|
150
199
|
tool: tool.name,
|
|
151
|
-
message: "No description. Agents pick tools by description
|
|
200
|
+
message: "No description. Agents pick tools by description. This tool is invisible."
|
|
152
201
|
});
|
|
153
202
|
continue;
|
|
154
203
|
}
|
|
@@ -156,7 +205,7 @@ function auditTools(tools, renames = []) {
|
|
|
156
205
|
findings.push({
|
|
157
206
|
level: "warning",
|
|
158
207
|
tool: tool.name,
|
|
159
|
-
message: `Description is just "${tool.description}" (no summary in the source). Write one sentence about what it does and why
|
|
208
|
+
message: `Description is just "${tool.description}" (no summary in the source). Write one sentence about what it does and why. It goes straight into the agent's prompt.`
|
|
160
209
|
});
|
|
161
210
|
}
|
|
162
211
|
if (AGENT_INSTRUCTION_PATTERN.test(tool.description)) {
|
|
@@ -170,21 +219,42 @@ function auditTools(tools, renames = []) {
|
|
|
170
219
|
findings.push({
|
|
171
220
|
level: "error",
|
|
172
221
|
tool: tool.name,
|
|
173
|
-
message: `The name suggests something destructive but ${tool.httpMethod} is a safe verb. Check the spec
|
|
222
|
+
message: `The name suggests something destructive but ${tool.httpMethod} is a safe verb. Check the spec: a GET named like a delete is either mislabeled or a design smell.`
|
|
174
223
|
});
|
|
175
224
|
}
|
|
176
225
|
if (tool.piiInOutput.length > 0) {
|
|
177
226
|
findings.push({
|
|
178
227
|
level: "warning",
|
|
179
228
|
tool: tool.name,
|
|
180
|
-
message: `Response may expose ${tool.piiInOutput.join(", ")}. These fields reach the agent
|
|
229
|
+
message: `Response may expose ${tool.piiInOutput.join(", ")}. These fields reach the agent. Exclude them in execute() unless they are truly needed.`
|
|
181
230
|
});
|
|
182
231
|
}
|
|
183
232
|
if (tool.requiresAuth && tool.riskTier !== "safe-read") {
|
|
184
233
|
findings.push({
|
|
185
234
|
level: "warning",
|
|
186
235
|
tool: tool.name,
|
|
187
|
-
message: "This mutating tool wraps an authenticated endpoint. It runs with the page's session
|
|
236
|
+
message: "This mutating tool wraps an authenticated endpoint. It runs with the page's session, so make sure your server-side authorization checks apply to tool calls too."
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (tool.endpointRole === "auth") {
|
|
240
|
+
findings.push({
|
|
241
|
+
level: "warning",
|
|
242
|
+
tool: tool.name,
|
|
243
|
+
message: "This looks like a sign-in or session endpoint. It is generated disabled: agents should not drive authentication. Enable it by hand only if you are sure."
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (tool.endpointRole === "admin") {
|
|
247
|
+
findings.push({
|
|
248
|
+
level: "warning",
|
|
249
|
+
tool: tool.name,
|
|
250
|
+
message: "Admin endpoint. It is generated disabled: exposing admin operations to agents should be a deliberate decision, reviewed endpoint by endpoint."
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (tool.httpMethod === "POST" && tool.sideEffect === "read") {
|
|
254
|
+
findings.push({
|
|
255
|
+
level: "warning",
|
|
256
|
+
tool: tool.name,
|
|
257
|
+
message: "A POST treated as a read (the name says search/query-style). If it actually changes state, disable it: a mislabeled read skips the user-confirmation step."
|
|
188
258
|
});
|
|
189
259
|
}
|
|
190
260
|
}
|
|
@@ -193,18 +263,38 @@ function auditTools(tools, renames = []) {
|
|
|
193
263
|
|
|
194
264
|
// src/pipeline.ts
|
|
195
265
|
async function runGenerate(config, options) {
|
|
266
|
+
const notes = [];
|
|
196
267
|
const candidates = (await Promise.all(config.sources.map((source) => source.collect()))).flat();
|
|
197
|
-
const
|
|
198
|
-
|
|
268
|
+
const stripped = stripVersionPrefix(candidates);
|
|
269
|
+
if (stripped.note) notes.push(stripped.note);
|
|
270
|
+
const versioned = candidates.map((candidate, index) => ({
|
|
271
|
+
...candidate,
|
|
272
|
+
name: stripped.names[index] ?? candidate.name
|
|
273
|
+
}));
|
|
274
|
+
const { names, renames } = dedupeNames(versioned);
|
|
275
|
+
const named = versioned.map((candidate, index) => {
|
|
199
276
|
const name = names[index] ?? candidate.name;
|
|
200
277
|
return { ...candidate, name, inputTypeName: `${pascalCase(name)}Input` };
|
|
201
278
|
});
|
|
202
|
-
const tools = reviewTools(named, config.safety);
|
|
279
|
+
const { tools, skipped } = reviewTools(named, config.safety);
|
|
280
|
+
if (options.overrides) {
|
|
281
|
+
for (const tool of tools) {
|
|
282
|
+
const override = options.overrides[tool.name];
|
|
283
|
+
if (!override) continue;
|
|
284
|
+
if (typeof override.description === "string" && override.description.trim()) {
|
|
285
|
+
tool.description = override.description.trim();
|
|
286
|
+
tool.descriptionSource = "openapi-summary";
|
|
287
|
+
}
|
|
288
|
+
if (typeof override.enabled === "boolean") {
|
|
289
|
+
tool.enabledByDefault = override.enabled;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
203
293
|
const findings = options.skipAudit ? [] : auditTools(tools, renames);
|
|
204
294
|
const errors = findings.filter((finding) => finding.level === "error");
|
|
205
295
|
const blocked = errors.length > 0 && !options.force && !options.skipAudit;
|
|
206
296
|
if (blocked) {
|
|
207
|
-
return { tools, findings, files: [], blocked, wrote: false };
|
|
297
|
+
return { tools, skipped, findings, files: [], notes, blocked, wrote: false };
|
|
208
298
|
}
|
|
209
299
|
const files = [];
|
|
210
300
|
for (const generator of config.generate) {
|
|
@@ -220,7 +310,7 @@ async function runGenerate(config, options) {
|
|
|
220
310
|
}
|
|
221
311
|
wrote = true;
|
|
222
312
|
}
|
|
223
|
-
return { tools, findings, files, blocked, wrote };
|
|
313
|
+
return { tools, skipped, findings, files, notes, blocked, wrote };
|
|
224
314
|
}
|
|
225
315
|
function conflictContents(file) {
|
|
226
316
|
return `// webmcp-codegen could not regenerate ${file.path} because its generated
|
|
@@ -235,4 +325,4 @@ export {
|
|
|
235
325
|
loadConfig,
|
|
236
326
|
runGenerate
|
|
237
327
|
};
|
|
238
|
-
//# sourceMappingURL=chunk-
|
|
328
|
+
//# sourceMappingURL=chunk-MJQ5B6HB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts","../src/pipeline.ts","../src/safety.ts"],"sourcesContent":["/**\n * Config: `defineConfig` for authoring, `loadConfig` for the CLI.\n *\n * Config files are plain JavaScript (`codegen.config.mjs`) so the CLI can\n * load them with a plain dynamic import. No TypeScript loader, no build\n * step, no extra dependencies. If you want types while authoring, that is\n * what `defineConfig` is for:\n *\n * import { defineConfig } from \"webmcp-codegen\";\n * export default defineConfig({ ... });\n */\n\nimport { access } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport type { CodegenConfig } from \"./types.js\";\n\n/** Identity function whose only job is type-checking the config object. */\nexport function defineConfig(config: CodegenConfig): CodegenConfig {\n return config;\n}\n\nexport const CONFIG_FILE_NAMES = [\"codegen.config.mjs\", \"codegen.config.js\"];\n\n/**\n * Find and load the config file. Resolving relative to `cwd` keeps the CLI\n * usable from any directory, the same way `eslint -c` behaves.\n */\nexport async function loadConfig(\n cwd: string,\n explicitPath?: string,\n): Promise<{ config: CodegenConfig; path: string }> {\n const candidates = explicitPath\n ? [resolve(cwd, explicitPath)]\n : CONFIG_FILE_NAMES.map((name) => join(cwd, name));\n\n for (const candidate of candidates) {\n if (!(await exists(candidate))) continue;\n const module = (await import(pathToFileURL(candidate).href)) as { default?: unknown };\n const config = module.default;\n if (!isCodegenConfig(config)) {\n throw new Error(\n `${candidate} must default-export defineConfig({ sources: [...], generate: [...] }).`,\n );\n }\n return { config, path: candidate };\n }\n\n throw new Error(\n explicitPath\n ? `No config file at \"${explicitPath}\".`\n : `No codegen.config.mjs found in ${cwd}. Run \\`npx webmcp-codegen init\\` to create one.`,\n );\n}\n\n/** The lightest possible shape check: a clear error beats deep validation. */\nfunction isCodegenConfig(value: unknown): value is CodegenConfig {\n if (value === null || typeof value !== \"object\") return false;\n const config = value as Record<string, unknown>;\n return Array.isArray(config.sources) && Array.isArray(config.generate);\n}\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n","/**\n * The pipeline: sources → normalize → safety review → audit → write.\n *\n * This module is the only place the stages meet. It owns no opinions of its\n * own; naming, safety, and file formats all live in their own modules. It\n * just runs them in order and produces one honest report of what happened\n * (or what *would* happen, when called with `write: false`).\n */\n\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { dedupeNames, stripVersionPrefix } from \"./naming.js\";\nimport { auditTools, reviewTools } from \"./safety.js\";\nimport { pascalCase } from \"./schema.js\";\nimport type {\n AuditFinding,\n CodegenConfig,\n GeneratedFile,\n ReviewedTool,\n SkippedEndpoint,\n ToolOverrides,\n} from \"./types.js\";\n\nexport interface GenerateOptions {\n /** Project root. Everything (config, spec paths, outDir) resolves from here. */\n cwd: string;\n /** Preview mode: compute everything, write nothing. */\n dryRun?: boolean;\n /** Skip the audit pass entirely (classification still runs; output needs it). */\n skipAudit?: boolean;\n /** Write even when the audit found errors. The report still shows them. */\n force?: boolean;\n /**\n * Hand-authored tweaks from .webmcp-codegen.json (usually written by the\n * dev dashboard). Applied after the safety review so they survive\n * regeneration.\n */\n overrides?: ToolOverrides;\n}\n\nexport interface GenerateResult {\n tools: ReviewedTool[];\n /** Endpoints deliberately not generated (webhooks, config exclusions). */\n skipped: SkippedEndpoint[];\n findings: AuditFinding[];\n files: GeneratedFile[];\n /** Human-facing pipeline notes, e.g. \"stripped the shared v1 prefix\". */\n notes: string[];\n /** True when audit errors stopped any file from being written. */\n blocked: boolean;\n /** True when this run actually wrote files (false for dry runs and blocks). */\n wrote: boolean;\n}\n\nexport async function runGenerate(\n config: CodegenConfig,\n options: GenerateOptions,\n): Promise<GenerateResult> {\n const notes: string[] = [];\n\n // 1. Collect candidate tools from every configured source.\n const candidates = (await Promise.all(config.sources.map((source) => source.collect()))).flat();\n\n // 2. Normalize names. First drop a shared API version prefix (\"get-v1-x\"\n // → \"get-x\") when nearly every name carries it, then dedupe what remains.\n // Strip before dedupe: stripping can create collisions, dedupe resolves them.\n const stripped = stripVersionPrefix(candidates);\n if (stripped.note) notes.push(stripped.note);\n const versioned = candidates.map((candidate, index) => ({\n ...candidate,\n name: stripped.names[index] ?? candidate.name,\n }));\n const { names, renames } = dedupeNames(versioned);\n const named = versioned.map((candidate, index) => {\n const name = names[index] ?? candidate.name;\n return { ...candidate, name, inputTypeName: `${pascalCase(name)}Input` };\n });\n\n // 3. Safety review: classify side effects, compute hints, scan for PII,\n // apply endpoint roles and config exclusions. Webhooks never come back.\n const { tools, skipped } = reviewTools(named, config.safety);\n\n // 4. Hand-authored overrides (dashboard edits) win over derived defaults.\n if (options.overrides) {\n for (const tool of tools) {\n const override = options.overrides[tool.name];\n if (!override) continue;\n if (typeof override.description === \"string\" && override.description.trim()) {\n tool.description = override.description.trim();\n // A hand-written description is no longer a template; stop warning about it.\n tool.descriptionSource = \"openapi-summary\";\n }\n if (typeof override.enabled === \"boolean\") {\n tool.enabledByDefault = override.enabled;\n }\n }\n }\n\n // 5. Audit. Errors block the write unless --force (or --skip-audit) was passed.\n const findings = options.skipAudit ? [] : auditTools(tools, renames);\n const errors = findings.filter((finding) => finding.level === \"error\");\n const blocked = errors.length > 0 && !options.force && !options.skipAudit;\n\n if (blocked) {\n return { tools, skipped, findings, files: [], notes, blocked, wrote: false };\n }\n\n // 6. Generate the files, then write them (unless this is a dry run).\n const files: GeneratedFile[] = [];\n for (const generator of config.generate) {\n files.push(...(await generator.generate(tools, options.cwd)));\n }\n\n let wrote = false;\n if (!options.dryRun) {\n for (const file of files) {\n if (file.action === \"unchanged\" && !file.conflict) continue;\n // A conflict means a human edited the generated region by hand:\n // leave their file alone and put our version in a `.new` sibling.\n const target = file.conflict ?? file.path;\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, file.conflict ? conflictContents(file) : file.contents);\n }\n wrote = true;\n }\n\n return { tools, skipped, findings, files, notes, blocked, wrote };\n}\n\n/**\n * When a hand-edited file blocks regeneration, the `.new` file explains\n * itself at the top so nobody mistakes it for something to import.\n */\nfunction conflictContents(file: GeneratedFile): string {\n return (\n `// webmcp-codegen could not regenerate ${file.path} because its generated\\n` +\n `// region was edited by hand. Review this version, then merge it manually.\\n\\n` +\n file.contents\n );\n}\n","/**\n * The safety layer.\n *\n * Nothing gets written to disk until every candidate tool has been through\n * here. This layer does three jobs:\n *\n * 1. Classify: what does calling this tool do to the world?\n * (read / write / destructive, from the HTTP verb plus name heuristics)\n * 2. Hint: derive the WebMCP tool hints (readOnlyHint etc.) from that\n * 3. Audit: lint the result and report problems in plain language\n *\n * Every rule is a heuristic with an escape hatch: the generated code carries\n * the classification in plain sight, and the developer owns the final file.\n */\n\nimport type {\n AuditFinding,\n CandidateTool,\n EndpointRole,\n JsonSchema,\n ReviewedTool,\n RiskTier,\n SafetyOptions,\n SideEffect,\n SkippedEndpoint,\n ToolHints,\n} from \"./types.js\";\n\n/**\n * Words that signal \"this changes something the user can't easily undo\",\n * even when the HTTP verb looks innocent. `POST /orders/{id}/cancel` is the\n * classic case: a POST that behaves like a DELETE.\n */\nconst DESTRUCTIVE_WORDS =\n /\\b(cancel|delete|remove|destroy|deactivate|refund|revoke|purge|close)\\b/i;\n\n/**\n * Words that mark a POST as a read in disguise. Plenty of real APIs search\n * with POST because the filter object is too big for a query string\n * (`POST /search`, `POST /estimate`). These get read treatment: enabled by\n * default, readOnlyHint set.\n *\n * Matched per dash-separated segment, so both route-derived names\n * (\"post-search\") and operationId-derived names (\"search-assets\") qualify,\n * while \"blacklist-items\" does not.\n */\nconst READING_POST_WORDS = new Set([\n \"search\",\n \"query\",\n \"list\",\n \"find\",\n \"filter\",\n \"estimate\",\n \"preview\",\n \"validate\",\n \"check\",\n \"lookup\",\n \"autocomplete\",\n \"suggest\",\n]);\n\n/** True when the tool's name contains a reading word as a whole segment. */\nfunction nameSaysRead(toolName: string): boolean {\n return toolName.split(\"-\").some((segment) => READING_POST_WORDS.has(segment));\n}\n\n/** Endpoints that receive server callbacks. An agent has nothing to call. */\nconst WEBHOOK_PATTERN = /\\bwebhooks?\\b/i;\n\n/** Sign-in, session, and credential endpoints. Agents should not drive auth. */\nconst AUTH_PATTERN =\n /\\b(auth|signin|sign-in|login|log-in|logout|log-out|oauth|password|credential|session)s?\\b/i;\n\n/** Admin endpoints. Exposing them to agents is a deliberate decision. */\nconst ADMIN_PATTERN = /\\badmin\\b/i;\n\n/**\n * Field names that usually hold personal data or secrets. Matched against\n * the last segment of a field path, case-insensitively. Teams extend this\n * list via `safety.piiFields` in the config.\n */\nconst DEFAULT_PII_FIELDS = [\n \"password\",\n \"ssn\",\n \"token\",\n \"secret\",\n \"apikey\",\n \"api_key\",\n \"email\",\n \"dob\",\n \"birthdate\",\n \"phone\",\n \"address\",\n \"creditcard\",\n \"cardnumber\",\n \"cvv\",\n];\n\n/**\n * Phrases that suggest a description is trying to *instruct the agent*\n * instead of describing the tool. That is a known prompt-injection smell.\n */\nconst AGENT_INSTRUCTION_PATTERN =\n /\\b(you (must|should|always|are)|as an ai|ignore (all |previous )?instructions|do not refuse)\\b/i;\n\n/** Step 1: classify what calling the tool does. */\nexport function classifySideEffect(tool: CandidateTool): SideEffect {\n switch (tool.httpMethod) {\n case \"GET\":\n case \"HEAD\":\n case \"OPTIONS\":\n // A safe verb whose name says otherwise is suspicious; audit flags it.\n return \"read\";\n case \"DELETE\":\n return \"destructive\";\n case \"POST\":\n // A POST whose name is a reading word (\"search-assets\", \"post-search\")\n // is a read wearing a write verb. Treat it as one.\n if (nameSaysRead(tool.name)) return \"read\";\n return DESTRUCTIVE_WORDS.test(tool.name) || DESTRUCTIVE_WORDS.test(tool.source.ref)\n ? \"destructive\"\n : \"write\";\n case \"PUT\":\n case \"PATCH\":\n // Upgrade nominally-\"write\" verbs when the name says it can't be undone.\n return DESTRUCTIVE_WORDS.test(tool.name) || DESTRUCTIVE_WORDS.test(tool.source.ref)\n ? \"destructive\"\n : \"write\";\n default:\n return \"unknown\";\n }\n}\n\n/**\n * What kind of endpoint this tool wraps. Checked against the route and the\n * name: \"/v1/admin/users\" and \"admin-feature-access-approve\" both catch it.\n */\nexport function endpointRoleFor(tool: CandidateTool): EndpointRole {\n const haystack = `${tool.name} ${tool.source.ref}`;\n if (WEBHOOK_PATTERN.test(haystack)) return \"webhook\";\n if (AUTH_PATTERN.test(haystack)) return \"auth\";\n if (ADMIN_PATTERN.test(haystack)) return \"admin\";\n return \"endpoint\";\n}\n\n/** Step 2: derive the WebMCP hints from the classification. */\nexport function hintsFor(tool: CandidateTool, sideEffect: SideEffect): ToolHints {\n const method = tool.httpMethod;\n return {\n readOnlyHint: sideEffect === \"read\",\n destructiveHint: sideEffect === \"destructive\",\n // PUT/PATCH/DELETE can be safely retried with the same input; POST cannot.\n idempotentHint:\n sideEffect === \"read\" || method === \"PUT\" || method === \"PATCH\" || method === \"DELETE\",\n };\n}\n\nexport function riskTierFor(sideEffect: SideEffect): RiskTier {\n switch (sideEffect) {\n case \"read\":\n return \"safe-read\";\n case \"destructive\":\n return \"destructive-confirm\";\n default:\n return \"write-confirm\";\n }\n}\n\n/**\n * Walk a schema and return the paths of fields that look like PII or\n * secrets, e.g. \"user.email\". Only *output* schemas are scanned: the\n * security-relevant direction is data leaving the page and reaching the agent.\n */\nexport function findPiiFields(\n schema: JsonSchema | undefined,\n extraFields: string[] = [],\n prefix = \"\",\n): string[] {\n if (!schema?.properties) return [];\n const piiNames = new Set(\n [...DEFAULT_PII_FIELDS, ...extraFields].map((name) => name.toLowerCase()),\n );\n const found: string[] = [];\n\n for (const [key, fieldSchema] of Object.entries(schema.properties)) {\n const path = prefix ? `${prefix}.${key}` : key;\n const normalizedKey = key.toLowerCase().replace(/[-_]/g, \"\");\n const looksSensitive =\n piiNames.has(key.toLowerCase()) ||\n piiNames.has(normalizedKey) ||\n [...piiNames].some((name) => normalizedKey === name.replace(/[-_]/g, \"\"));\n if (looksSensitive) found.push(path);\n // Recurse into nested objects (\"user\": { \"email\": ... }).\n found.push(...findPiiFields(fieldSchema, extraFields, path));\n }\n return found;\n}\n\n/**\n * Run the full review: classify, hint, PII-scan, decide the starting state.\n * Pure, no I/O.\n *\n * Returns the surviving tools plus the endpoints we deliberately skipped\n * (webhooks today), so the report can say what was left out and why.\n */\nexport function reviewTools(\n candidates: CandidateTool[],\n safety: SafetyOptions = {},\n): { tools: ReviewedTool[]; skipped: SkippedEndpoint[] } {\n const excluded = (safety.exclude ?? []).map((pattern) => pattern.toLowerCase());\n const skipped: SkippedEndpoint[] = [];\n const tools: ReviewedTool[] = [];\n\n for (const tool of candidates) {\n const excludedBy = excluded.find(\n (pattern) =>\n tool.name.toLowerCase().includes(pattern) ||\n tool.source.ref.toLowerCase().includes(pattern),\n );\n if (excludedBy) {\n skipped.push({ ref: tool.source.ref, reason: `excluded by config (\"${excludedBy}\")` });\n continue;\n }\n\n const endpointRole = endpointRoleFor(tool);\n if (endpointRole === \"webhook\") {\n skipped.push({\n ref: tool.source.ref,\n reason: \"a webhook receives server callbacks; an agent has nothing to call\",\n });\n continue;\n }\n\n const sideEffect = classifySideEffect(tool);\n tools.push({\n ...tool,\n sideEffect,\n endpointRole,\n riskTier: riskTierFor(sideEffect),\n hints: hintsFor(tool, sideEffect),\n // Reads work out of the box. Mutations, auth, and admin endpoints start\n // disabled: the working code is generated but commented out, so enabling\n // one is a deliberate edit, never an accident.\n enabledByDefault: sideEffect === \"read\" && endpointRole === \"endpoint\",\n piiInOutput: findPiiFields(tool.outputSchema, safety.piiFields),\n });\n }\n\n return { tools, skipped };\n}\n\n/**\n * Step 3: audit the reviewed tools and report in plain language.\n * Errors block file writing (unless --force); warnings never do. This is\n * meant to run in CI like `npm audit`: exit codes, not vibes.\n */\nexport function auditTools(\n tools: ReviewedTool[],\n renames: { from: string; to: string }[] = [],\n): AuditFinding[] {\n const findings: AuditFinding[] = [];\n\n for (const rename of renames) {\n findings.push({\n level: \"warning\",\n tool: rename.to,\n message: `Renamed \"${rename.from}\" → \"${rename.to}\" to keep tool names unique.`,\n });\n }\n\n for (const tool of tools) {\n if (!tool.description || tool.description.trim().length === 0) {\n findings.push({\n level: \"error\",\n tool: tool.name,\n message: \"No description. Agents pick tools by description. This tool is invisible.\",\n });\n continue;\n }\n\n if (tool.descriptionSource === \"generated-template\") {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n `Description is just \"${tool.description}\" (no summary in the source). ` +\n \"Write one sentence about what it does and why. It goes straight into the agent's prompt.\",\n });\n }\n\n if (AGENT_INSTRUCTION_PATTERN.test(tool.description)) {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n \"The description reads like instructions to the agent, not a description of the tool. \" +\n \"Describe what the tool does; never try to steer the agent from here.\",\n });\n }\n\n if (tool.riskTier === \"safe-read\" && DESTRUCTIVE_WORDS.test(tool.name)) {\n findings.push({\n level: \"error\",\n tool: tool.name,\n message:\n `The name suggests something destructive but ${tool.httpMethod} is a safe verb. ` +\n \"Check the spec: a GET named like a delete is either mislabeled or a design smell.\",\n });\n }\n\n if (tool.piiInOutput.length > 0) {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n `Response may expose ${tool.piiInOutput.join(\", \")}. ` +\n \"These fields reach the agent. Exclude them in execute() unless they are truly needed.\",\n });\n }\n\n if (tool.requiresAuth && tool.riskTier !== \"safe-read\") {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n \"This mutating tool wraps an authenticated endpoint. It runs with the page's session, so \" +\n \"make sure your server-side authorization checks apply to tool calls too.\",\n });\n }\n\n if (tool.endpointRole === \"auth\") {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n \"This looks like a sign-in or session endpoint. It is generated disabled: \" +\n \"agents should not drive authentication. Enable it by hand only if you are sure.\",\n });\n }\n\n if (tool.endpointRole === \"admin\") {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n \"Admin endpoint. It is generated disabled: exposing admin operations to agents \" +\n \"should be a deliberate decision, reviewed endpoint by endpoint.\",\n });\n }\n\n if (tool.httpMethod === \"POST\" && tool.sideEffect === \"read\") {\n findings.push({\n level: \"warning\",\n tool: tool.name,\n message:\n \"A POST treated as a read (the name says search/query-style). If it actually \" +\n \"changes state, disable it: a mislabeled read skips the user-confirmation step.\",\n });\n }\n }\n\n return findings;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
jsonSchemaToTs,
|
|
3
|
+
pascalCase
|
|
4
|
+
} from "./chunk-KSQMJERY.js";
|
|
5
|
+
|
|
6
|
+
// src/generators/js.ts
|
|
7
|
+
import { readFile } from "fs/promises";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
// src/generators/js-templates.ts
|
|
11
|
+
function generatedRegion(tool) {
|
|
12
|
+
const pascal = pascalCase(tool.name);
|
|
13
|
+
const camel = lowercaseFirst(pascal);
|
|
14
|
+
const schemaJson = JSON.stringify(tool.inputSchema, null, 2);
|
|
15
|
+
const inputType = jsonSchemaToTs(tool.inputSchema, void 0);
|
|
16
|
+
const mutates = tool.riskTier !== "safe-read";
|
|
17
|
+
const runtimeImports = [
|
|
18
|
+
"getModelContext",
|
|
19
|
+
...mutates ? ["requestUserConfirmation"] : [],
|
|
20
|
+
"callApi",
|
|
21
|
+
"toolResult",
|
|
22
|
+
...tool.enabledByDefault ? [] : ["toolDisabled"]
|
|
23
|
+
].join(", ");
|
|
24
|
+
const registerBody = mutates ? [
|
|
25
|
+
` await modelContext.registerTool(`,
|
|
26
|
+
` {`,
|
|
27
|
+
` ...${camel}Tool,`,
|
|
28
|
+
` execute: async (input) => {`,
|
|
29
|
+
` // This tool changes things, so the user is always asked first. The`,
|
|
30
|
+
` // confirmation lives in the generated region: it cannot be edited away.`,
|
|
31
|
+
` const confirmed = await requestUserConfirmation(`,
|
|
32
|
+
` ${JSON.stringify(`Allow the agent to: ${tool.description}`)},`,
|
|
33
|
+
` );`,
|
|
34
|
+
` if (!confirmed) {`,
|
|
35
|
+
` return {`,
|
|
36
|
+
` content: [{ type: "text", text: "The user declined this action." }],`,
|
|
37
|
+
` isError: true,`,
|
|
38
|
+
` };`,
|
|
39
|
+
` }`,
|
|
40
|
+
` // The browser has already validated the agent's input against the schema.`,
|
|
41
|
+
` return execute${pascal}(input as ${tool.inputTypeName});`,
|
|
42
|
+
` },`,
|
|
43
|
+
` },`,
|
|
44
|
+
` { signal },`,
|
|
45
|
+
` );`
|
|
46
|
+
] : [
|
|
47
|
+
` await modelContext.registerTool(`,
|
|
48
|
+
` {`,
|
|
49
|
+
` ...${camel}Tool,`,
|
|
50
|
+
` // The browser has already validated the agent's input against the schema.`,
|
|
51
|
+
` execute: (input) => execute${pascal}(input as ${tool.inputTypeName}),`,
|
|
52
|
+
` },`,
|
|
53
|
+
` { signal },`,
|
|
54
|
+
` );`
|
|
55
|
+
];
|
|
56
|
+
return [
|
|
57
|
+
`import { ${runtimeImports} } from "./runtime.webmcp";`,
|
|
58
|
+
``,
|
|
59
|
+
GENERATED_START,
|
|
60
|
+
`/**`,
|
|
61
|
+
` * ${tool.description}`,
|
|
62
|
+
` *`,
|
|
63
|
+
` * Source: ${tool.source.ref} (${tool.source.kind}). Risk: ${tool.riskTier}.`,
|
|
64
|
+
` * Starts ${tool.enabledByDefault ? "enabled" : "disabled"} (see execute${pascal} below).`,
|
|
65
|
+
` * Regenerate with: npx webmcp-codegen generate`,
|
|
66
|
+
` */`,
|
|
67
|
+
``,
|
|
68
|
+
`/** The exact contract advertised to the agent. Derived from the API spec. Do not hand-edit. */`,
|
|
69
|
+
`export const ${camel}InputSchema = ${schemaJson};`,
|
|
70
|
+
``,
|
|
71
|
+
`/** What \`execute\` receives. The browser validates agent input against the schema above. */`,
|
|
72
|
+
`export type ${tool.inputTypeName} = ${inputType};`,
|
|
73
|
+
``,
|
|
74
|
+
`/** Safety hints computed by webmcp-codegen. Informational metadata for hosts and UIs. */`,
|
|
75
|
+
`export const ${camel}Hints = ${JSON.stringify(tool.hints)} as const;`,
|
|
76
|
+
``,
|
|
77
|
+
`/** The tool definition, minus \`execute\` (which is yours, below the marker). */`,
|
|
78
|
+
`export const ${camel}Tool = {`,
|
|
79
|
+
` name: ${JSON.stringify(tool.name)},`,
|
|
80
|
+
` description: ${JSON.stringify(tool.description)},`,
|
|
81
|
+
` inputSchema: ${camel}InputSchema,`,
|
|
82
|
+
`};`,
|
|
83
|
+
``,
|
|
84
|
+
`/**`,
|
|
85
|
+
` * Register this tool with WebMCP. Call it once on page load, or use`,
|
|
86
|
+
` * registerAllTools() from the generated index.ts.`,
|
|
87
|
+
` *`,
|
|
88
|
+
` * Pass an AbortSignal to unregister later: controller.abort().`,
|
|
89
|
+
` */`,
|
|
90
|
+
`export async function register${pascal}(signal?: AbortSignal): Promise<void> {`,
|
|
91
|
+
` const modelContext = getModelContext();`,
|
|
92
|
+
...registerBody,
|
|
93
|
+
`}`,
|
|
94
|
+
``,
|
|
95
|
+
GENERATED_END
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
function ownedRegionScaffold(tool) {
|
|
99
|
+
const pascal = pascalCase(tool.name);
|
|
100
|
+
const call = requestCall(tool);
|
|
101
|
+
const lines = [
|
|
102
|
+
``,
|
|
103
|
+
`/**`,
|
|
104
|
+
` * What actually happens when the agent calls "${tool.name}".`,
|
|
105
|
+
` *`,
|
|
106
|
+
` * Default implementation: calls ${tool.source.ref} from this page, with the`,
|
|
107
|
+
` * signed-in user's session. Replace it with your app's own API client`,
|
|
108
|
+
` * whenever you like; the contract above never changes.`
|
|
109
|
+
];
|
|
110
|
+
if (tool.serverUrl) {
|
|
111
|
+
lines.push(
|
|
112
|
+
` *`,
|
|
113
|
+
` * Your spec lists the API at ${tool.serverUrl}. If the app and the API`,
|
|
114
|
+
` * are on different hosts, pass the full URL to callApi instead.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (tool.riskTier !== "safe-read") {
|
|
118
|
+
lines.push(
|
|
119
|
+
` *`,
|
|
120
|
+
` * This tool is ${tool.riskTier}: it ${tool.riskTier === "destructive-confirm" ? "cannot easily be undone" : "changes things"}.`,
|
|
121
|
+
` * The user is asked to confirm every call (built into the generated region).`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
lines.push(` */`);
|
|
125
|
+
if (tool.piiInOutput.length > 0) {
|
|
126
|
+
lines.push(
|
|
127
|
+
`//`,
|
|
128
|
+
`// \u26A0 webmcp-codegen flagged these response fields as likely PII: ${tool.piiInOutput.join(", ")}.`,
|
|
129
|
+
`// Everything you return reaches the agent. Leave those fields out of what you`,
|
|
130
|
+
`// return unless the agent genuinely needs them, and say so in a comment if you keep them.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (tool.enabledByDefault) {
|
|
134
|
+
lines.push(
|
|
135
|
+
`export async function execute${pascal}(input: ${tool.inputTypeName}) {`,
|
|
136
|
+
` ${call}`,
|
|
137
|
+
` return toolResult(data);`,
|
|
138
|
+
`}`
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
lines.push(
|
|
142
|
+
`export async function execute${pascal}(input: ${tool.inputTypeName}) {`,
|
|
143
|
+
` // This tool starts disabled: it ${tool.endpointRole === "endpoint" ? "changes things" : `wraps an ${tool.endpointRole} endpoint`}. Agents can see it, and calling it tells`,
|
|
144
|
+
` // them it is disabled. To enable it, delete the line below and uncomment the code.`,
|
|
145
|
+
` return toolDisabled("${tool.name}.webmcp.ts");`,
|
|
146
|
+
``,
|
|
147
|
+
` // ${call}`,
|
|
148
|
+
` // return toolResult(data);`,
|
|
149
|
+
`}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
function requestCall(tool) {
|
|
155
|
+
if (!tool.httpMethod || !tool.pathTemplate || !tool.paramLocations) {
|
|
156
|
+
return `const data = null; // TODO: call your app's existing code here.`;
|
|
157
|
+
}
|
|
158
|
+
const { path: pathParams, query: queryParams, body: bodyParams } = tool.paramLocations;
|
|
159
|
+
let pathExpr = `\`${tool.pathTemplate.replace(/\{([^}]+)\}/g, (_m, param) => `\${${inputRef(param)}}`)}\``;
|
|
160
|
+
if (pathParams.length === 0) pathExpr = JSON.stringify(tool.pathTemplate);
|
|
161
|
+
const options = [`method: ${JSON.stringify(tool.httpMethod)}`];
|
|
162
|
+
if (queryParams.length > 0) {
|
|
163
|
+
const entries = queryParams.map((name) => `${safeKey(name)}: ${inputRef(name)}`).join(", ");
|
|
164
|
+
options.push(`query: { ${entries} }`);
|
|
165
|
+
}
|
|
166
|
+
if (bodyParams.length > 0) {
|
|
167
|
+
if (bodyParams.length === 1 && bodyParams[0] === "body") {
|
|
168
|
+
options.push(`body: input.body`);
|
|
169
|
+
} else {
|
|
170
|
+
const entries = bodyParams.map((name) => `${safeKey(name)}: ${inputRef(name)}`).join(", ");
|
|
171
|
+
options.push(`body: { ${entries} }`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return `const data = await callApi(${pathExpr}, { ${options.join(", ")} });`;
|
|
175
|
+
}
|
|
176
|
+
function inputRef(name) {
|
|
177
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? `input.${name}` : `input[${JSON.stringify(name)}]`;
|
|
178
|
+
}
|
|
179
|
+
function safeKey(name) {
|
|
180
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
181
|
+
}
|
|
182
|
+
function runtimeSource() {
|
|
183
|
+
return `/**
|
|
184
|
+
* Generated by webmcp-codegen. This file is fully regenerated on every run.
|
|
185
|
+
* Do not edit by hand; your changes will be lost.
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/** The result shape tools return (same as MCP tool results). */
|
|
189
|
+
export interface WebMcpToolResult {
|
|
190
|
+
content: { type: "text"; text: string }[];
|
|
191
|
+
isError?: boolean;
|
|
192
|
+
[key: string]: unknown;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A tool as the browser runtime understands it. */
|
|
196
|
+
export interface WebMcpToolDefinition {
|
|
197
|
+
name: string;
|
|
198
|
+
description: string;
|
|
199
|
+
inputSchema?: Record<string, unknown>;
|
|
200
|
+
execute: (input: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The slice of the WebMCP draft spec the generated code uses. */
|
|
204
|
+
export interface ModelContext {
|
|
205
|
+
registerTool(
|
|
206
|
+
tool: WebMcpToolDefinition,
|
|
207
|
+
options?: { signal?: AbortSignal },
|
|
208
|
+
): Promise<void>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Access the page's WebMCP model context, with a helpful error when the
|
|
213
|
+
* browser doesn't have one (rather than an undefined-callsite mystery).
|
|
214
|
+
*/
|
|
215
|
+
export function getModelContext(): ModelContext {
|
|
216
|
+
const modelContext = (document as unknown as { modelContext?: ModelContext }).modelContext;
|
|
217
|
+
if (!modelContext) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
"WebMCP is not available in this browser. " +
|
|
220
|
+
"Enable chrome://flags/#enable-webmcp-testing (Chrome 146+), " +
|
|
221
|
+
"or add the WebMCP polyfill to your app.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return modelContext;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Call your API from the page. Same origin by default (pass a full URL when
|
|
229
|
+
* the API lives on another host), always with the signed-in user's session
|
|
230
|
+
* cookies. Throws on HTTP errors; returns the parsed JSON body, or raw text
|
|
231
|
+
* when the response is not JSON.
|
|
232
|
+
*/
|
|
233
|
+
export async function callApi(
|
|
234
|
+
path: string,
|
|
235
|
+
options: { method?: string; query?: Record<string, unknown>; body?: unknown } = {},
|
|
236
|
+
): Promise<unknown> {
|
|
237
|
+
const url = new URL(path, window.location.origin);
|
|
238
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
239
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
240
|
+
}
|
|
241
|
+
const response = await fetch(url, {
|
|
242
|
+
method: options.method ?? "GET",
|
|
243
|
+
credentials: "include",
|
|
244
|
+
headers: options.body !== undefined ? { "content-type": "application/json" } : undefined,
|
|
245
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
246
|
+
});
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
throw new Error("Request failed: " + response.status + " " + response.statusText);
|
|
249
|
+
}
|
|
250
|
+
if (response.status === 204) return null;
|
|
251
|
+
const text = await response.text();
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
} catch {
|
|
255
|
+
return text;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Wrap a result in the MCP shape, so tool bodies stay one line. */
|
|
260
|
+
export function toolResult(data: unknown): WebMcpToolResult {
|
|
261
|
+
return {
|
|
262
|
+
content: [
|
|
263
|
+
{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) },
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* What a disabled tool tells the agent. The tool stays visible (so the agent
|
|
270
|
+
* knows it exists and can ask the human to enable it) but does nothing.
|
|
271
|
+
*/
|
|
272
|
+
export function toolDisabled(fileName: string): WebMcpToolResult {
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "text",
|
|
277
|
+
text:
|
|
278
|
+
"This tool is currently disabled by the app developer. Ask them to enable it " +
|
|
279
|
+
"(uncomment the implementation in " + fileName + ").",
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
isError: true,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Default "agent proposes, human confirms" gate for write/destructive tools.
|
|
288
|
+
* Deliberately minimal (window.confirm). Replace it with your app's own
|
|
289
|
+
* dialog when you outgrow it. The point is that the user always gets a say.
|
|
290
|
+
*/
|
|
291
|
+
export function requestUserConfirmation(message: string): Promise<boolean> {
|
|
292
|
+
return Promise.resolve(window.confirm(message));
|
|
293
|
+
}
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
function barrelSource(tools) {
|
|
297
|
+
const imports = tools.map((tool) => `import { register${pascalCase(tool.name)} } from "./${tool.name}.webmcp";`).join("\n");
|
|
298
|
+
const names = tools.map((tool) => `register${pascalCase(tool.name)}`).join(",\n ");
|
|
299
|
+
return `/**
|
|
300
|
+
* Generated by webmcp-codegen. This file is fully regenerated on every run.
|
|
301
|
+
* Import registerAllTools() once at app startup:
|
|
302
|
+
*
|
|
303
|
+
* import { registerAllTools } from "./webmcp";
|
|
304
|
+
* await registerAllTools();
|
|
305
|
+
*/
|
|
306
|
+
|
|
307
|
+
${imports}
|
|
308
|
+
|
|
309
|
+
const registrations = [
|
|
310
|
+
${names}
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Register every generated tool with WebMCP. One tool failing (for example
|
|
315
|
+
* because the page's Permissions-Policy disables tools) never takes the
|
|
316
|
+
* others down with it. The failure is logged and registration continues.
|
|
317
|
+
*/
|
|
318
|
+
export async function registerAllTools(signal?: AbortSignal): Promise<void> {
|
|
319
|
+
for (const register of registrations) {
|
|
320
|
+
try {
|
|
321
|
+
await register(signal);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.warn("[webmcp-codegen] a tool failed to register:", error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
function lowercaseFirst(pascal) {
|
|
330
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/generators/js.ts
|
|
334
|
+
var GENERATED_START = "// \u2500\u2500\u2500 webmcp-codegen: generated. Do not edit this region. \u2500\u2500\u2500";
|
|
335
|
+
var GENERATED_END = "// \u2500\u2500\u2500 webmcp-codegen: end generated. Your code below survives regeneration. \u2500\u2500\u2500";
|
|
336
|
+
function js(options) {
|
|
337
|
+
return {
|
|
338
|
+
kind: "js",
|
|
339
|
+
outDir: options.outDir,
|
|
340
|
+
async generate(tools, cwd) {
|
|
341
|
+
const outDir = join(cwd, options.outDir);
|
|
342
|
+
const files = [];
|
|
343
|
+
files.push(await plainFile(join(outDir, "runtime.webmcp.ts"), runtimeSource()));
|
|
344
|
+
files.push(await plainFile(join(outDir, "index.ts"), barrelSource(tools)));
|
|
345
|
+
for (const tool of tools) {
|
|
346
|
+
files.push(await toolFile(tool, outDir));
|
|
347
|
+
}
|
|
348
|
+
return files;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function plainFile(path, contents) {
|
|
353
|
+
try {
|
|
354
|
+
const existing = await readFile(path, "utf8");
|
|
355
|
+
return { path, contents, action: existing === contents ? "unchanged" : "update" };
|
|
356
|
+
} catch {
|
|
357
|
+
return { path, contents, action: "create" };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function toolFile(tool, outDir) {
|
|
361
|
+
const path = join(outDir, `${tool.name}.webmcp.ts`);
|
|
362
|
+
const head = generatedRegion(tool);
|
|
363
|
+
let existing;
|
|
364
|
+
try {
|
|
365
|
+
existing = await readFile(path, "utf8");
|
|
366
|
+
} catch {
|
|
367
|
+
return { path, contents: `${head}
|
|
368
|
+
${ownedRegionScaffold(tool)}`, action: "create" };
|
|
369
|
+
}
|
|
370
|
+
const markerIndex = existing.indexOf(GENERATED_END);
|
|
371
|
+
if (markerIndex === -1) {
|
|
372
|
+
return { path, contents: existing, action: "unchanged", conflict: `${path}.new` };
|
|
373
|
+
}
|
|
374
|
+
const preservedTail = existing.slice(markerIndex + GENERATED_END.length);
|
|
375
|
+
const contents = head + preservedTail;
|
|
376
|
+
return { path, contents, action: contents === existing ? "unchanged" : "update" };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export {
|
|
380
|
+
js
|
|
381
|
+
};
|
|
382
|
+
//# sourceMappingURL=chunk-TGOJ3HUE.js.map
|