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.
@@ -1,9 +1,10 @@
1
1
  import {
2
- dedupeNames
3
- } from "./chunk-BIKKPCRT.js";
2
+ dedupeNames,
3
+ stripVersionPrefix
4
+ } from "./chunk-FWSATV7C.js";
4
5
  import {
5
6
  pascalCase
6
- } from "./chunk-5L4KN6F4.js";
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
- return candidates.filter(
123
- (tool) => !excluded.some(
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
- ).map((tool) => {
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
- return {
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 \u2014 this tool is invisible."
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 \u2014 it goes straight into the agent's prompt.`
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 \u2014 a GET named like a delete is either mislabeled or a design smell.`
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 \u2014 exclude them in execute() unless they are truly needed.`
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 \u2014 make sure your server-side authorization checks apply to tool calls too."
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 { names, renames } = dedupeNames(candidates);
198
- const named = candidates.map((candidate, index) => {
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-R3DEBBQ3.js.map
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