webmcp-codegen 0.2.1 → 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,6 +1,7 @@
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
7
  } from "./chunk-KSQMJERY.js";
@@ -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 = [];
@@ -187,24 +236,65 @@ function auditTools(tools, renames = []) {
187
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."
188
237
  });
189
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."
258
+ });
259
+ }
190
260
  }
191
261
  return findings;
192
262
  }
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-LYHLGSAI.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":[]}
@@ -13,14 +13,55 @@ function generatedRegion(tool) {
13
13
  const camel = lowercaseFirst(pascal);
14
14
  const schemaJson = JSON.stringify(tool.inputSchema, null, 2);
15
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
+ ];
16
56
  return [
17
- `import { getModelContext } from "./runtime.webmcp";`,
57
+ `import { ${runtimeImports} } from "./runtime.webmcp";`,
18
58
  ``,
19
59
  GENERATED_START,
20
60
  `/**`,
21
61
  ` * ${tool.description}`,
22
62
  ` *`,
23
63
  ` * Source: ${tool.source.ref} (${tool.source.kind}). Risk: ${tool.riskTier}.`,
64
+ ` * Starts ${tool.enabledByDefault ? "enabled" : "disabled"} (see execute${pascal} below).`,
24
65
  ` * Regenerate with: npx webmcp-codegen generate`,
25
66
  ` */`,
26
67
  ``,
@@ -48,14 +89,7 @@ function generatedRegion(tool) {
48
89
  ` */`,
49
90
  `export async function register${pascal}(signal?: AbortSignal): Promise<void> {`,
50
91
  ` const modelContext = getModelContext();`,
51
- ` await modelContext.registerTool(`,
52
- ` {`,
53
- ` ...${camel}Tool,`,
54
- ` // The browser has already validated the agent's input against the schema.`,
55
- ` execute: (input) => execute${pascal}(input as ${tool.inputTypeName}),`,
56
- ` },`,
57
- ` { signal },`,
58
- ` );`,
92
+ ...registerBody,
59
93
  `}`,
60
94
  ``,
61
95
  GENERATED_END
@@ -63,19 +97,28 @@ function generatedRegion(tool) {
63
97
  }
64
98
  function ownedRegionScaffold(tool) {
65
99
  const pascal = pascalCase(tool.name);
100
+ const call = requestCall(tool);
66
101
  const lines = [
67
102
  ``,
68
103
  `/**`,
69
104
  ` * What actually happens when the agent calls "${tool.name}".`,
70
105
  ` *`,
71
- ` * Source: ${tool.source.ref}. Call your existing client code here.`,
72
- ` * Return { content: [{ type: "text", text: ... }] } (the MCP result shape).`
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.`
73
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
+ }
74
117
  if (tool.riskTier !== "safe-read") {
75
118
  lines.push(
76
119
  ` *`,
77
- ` * \u26A0 This tool is ${tool.riskTier}: it ${tool.riskTier === "destructive-confirm" ? "cannot easily be undone" : "changes things"}.`,
78
- ` * Ask the user before acting. See requestUserConfirmation() in runtime.webmcp.ts.`
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).`
79
122
  );
80
123
  }
81
124
  lines.push(` */`);
@@ -83,31 +126,58 @@ function ownedRegionScaffold(tool) {
83
126
  lines.push(
84
127
  `//`,
85
128
  `// \u26A0 webmcp-codegen flagged these response fields as likely PII: ${tool.piiInOutput.join(", ")}.`,
86
- `// Everything you return reaches the agent. Leave those fields out unless`,
87
- `// the agent genuinely needs them, and say so in a comment if you keep them.`
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
+ `}`
88
150
  );
89
151
  }
90
- lines.push(
91
- `export async function execute${pascal}(input: ${tool.inputTypeName}) {`,
92
- ...usageExample(tool),
93
- ` throw new Error("Not implemented: execute${pascal}");`,
94
- `}`
95
- );
96
152
  return lines.join("\n");
97
153
  }
98
- function usageExample(tool) {
99
- if (!tool.httpMethod) {
100
- return [` // TODO: implement using your app's existing code.`];
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.`;
101
157
  }
102
- const path = tool.source.ref.replace(/^[A-Z]+ /, "");
103
- const exampleUrl = path.replace(/\{(\w+)\}/g, (_match, param) => `" + input.${param} + "`).replace(/^"" \+ /, "").replace(/ \+ ""$/, "");
104
- const fetchArgs = tool.httpMethod === "GET" ? `"${exampleUrl}"` : `"${exampleUrl}", { method: "${tool.httpMethod}" }`;
105
- return [
106
- ` // TODO: implement using your app's existing code, e.g.:`,
107
- ` // const response = await fetch(${fetchArgs});`,
108
- ` // if (!response.ok) throw new Error("Request failed: " + response.status);`,
109
- ` // return { content: [{ type: "text", text: "Done" }] };`
110
- ];
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);
111
181
  }
112
182
  function runtimeSource() {
113
183
  return `/**
@@ -118,6 +188,7 @@ function runtimeSource() {
118
188
  /** The result shape tools return (same as MCP tool results). */
119
189
  export interface WebMcpToolResult {
120
190
  content: { type: "text"; text: string }[];
191
+ isError?: boolean;
121
192
  [key: string]: unknown;
122
193
  }
123
194
 
@@ -153,6 +224,65 @@ export function getModelContext(): ModelContext {
153
224
  return modelContext;
154
225
  }
155
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
+
156
286
  /**
157
287
  * Default "agent proposes, human confirms" gate for write/destructive tools.
158
288
  * Deliberately minimal (window.confirm). Replace it with your app's own
@@ -206,6 +336,7 @@ var GENERATED_END = "// \u2500\u2500\u2500 webmcp-codegen: end generated. Your c
206
336
  function js(options) {
207
337
  return {
208
338
  kind: "js",
339
+ outDir: options.outDir,
209
340
  async generate(tools, cwd) {
210
341
  const outDir = join(cwd, options.outDir);
211
342
  const files = [];
@@ -248,4 +379,4 @@ ${ownedRegionScaffold(tool)}`, action: "create" };
248
379
  export {
249
380
  js
250
381
  };
251
- //# sourceMappingURL=chunk-OILNQ2HE.js.map
382
+ //# sourceMappingURL=chunk-TGOJ3HUE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/generators/js.ts","../src/generators/js-templates.ts"],"sourcesContent":["/**\n * The `js` generator, named after what lands in your repo: plain JavaScript/\n * TypeScript files that call the spec's imperative API\n * (`document.modelContext.registerTool`).\n *\n * Output layout for `js({ outDir: \"./src/webmcp\" })`:\n *\n * src/webmcp/\n * ├── runtime.webmcp.ts ← fully generated, never edit\n * ├── index.ts ← fully generated, registers everything\n * ├── get-order-status.webmcp.ts ← generated contract + YOUR execute()\n * └── ...\n *\n * Each per-tool file has two regions, divided by marker comments:\n *\n * generated region schema, input type, tool definition, register()\n * ── end generated ── everything below survives regeneration\n * your region execute(), scaffolded once, then owned by you\n *\n * This file contains only the *file mechanics*: which files exist, and how to\n * update them without destroying hand-written code. The text of the generated\n * code itself lives in js-templates.ts, keeping \"what the output looks like\"\n * separate from \"how files get written\" is what keeps both readable.\n */\n\nimport { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { GeneratedFile, ReviewedTool, ToolGenerator } from \"../types.js\";\nimport {\n barrelSource,\n generatedRegion,\n ownedRegionScaffold,\n runtimeSource,\n} from \"./js-templates.js\";\n\nexport interface JsGeneratorOptions {\n /** Where the tool files go, relative to the project root. */\n outDir: string;\n}\n\n/**\n * The marker lines that split a per-tool file in two. They are the merge\n * contract: we may rewrite everything up to and including GENERATED_END,\n * and we must never touch anything after it. js-templates.ts imports these\n * so the marker text is defined in exactly one place.\n */\nexport const GENERATED_START = \"// ─── webmcp-codegen: generated. Do not edit this region. ───\";\nexport const GENERATED_END =\n \"// ─── webmcp-codegen: end generated. Your code below survives regeneration. ───\";\n\n/** Create the `js` generator for the config's `generate` array. */\nexport function js(options: JsGeneratorOptions): ToolGenerator {\n return {\n kind: \"js\",\n outDir: options.outDir,\n async generate(tools, cwd) {\n const outDir = join(cwd, options.outDir);\n const files: GeneratedFile[] = [];\n\n // The runtime and the barrel are regenerated wholesale every run;\n // their headers say \"do not edit\", and we mean it.\n files.push(await plainFile(join(outDir, \"runtime.webmcp.ts\"), runtimeSource()));\n files.push(await plainFile(join(outDir, \"index.ts\"), barrelSource(tools)));\n\n for (const tool of tools) {\n files.push(await toolFile(tool, outDir));\n }\n return files;\n },\n };\n}\n\n/** A fully-generated file: create if missing, overwrite if changed, skip if same. */\nasync function plainFile(path: string, contents: string): Promise<GeneratedFile> {\n try {\n const existing = await readFile(path, \"utf8\");\n return { path, contents, action: existing === contents ? \"unchanged\" : \"update\" };\n } catch {\n return { path, contents, action: \"create\" };\n }\n}\n\n/**\n * Build (or merge) one per-tool file. The only I/O here is reading the\n * existing file to check for a hand-written region worth keeping.\n */\nasync function toolFile(tool: ReviewedTool, outDir: string): Promise<GeneratedFile> {\n const path = join(outDir, `${tool.name}.webmcp.ts`);\n const head = generatedRegion(tool);\n\n let existing: string | undefined;\n try {\n existing = await readFile(path, \"utf8\");\n } catch {\n // No file yet: brand new tool, so we also lay down the execute() scaffold.\n return { path, contents: `${head}\\n${ownedRegionScaffold(tool)}`, action: \"create\" };\n }\n\n const markerIndex = existing.indexOf(GENERATED_END);\n if (markerIndex === -1) {\n // Someone removed the markers or hand-wrote this path from scratch.\n // Never clobber their work: report a conflict and let the pipeline put\n // our version in a `.new` sibling for a human to merge.\n return { path, contents: existing, action: \"unchanged\", conflict: `${path}.new` };\n }\n\n // Keep everything the developer wrote below the marker, word for word.\n const preservedTail = existing.slice(markerIndex + GENERATED_END.length);\n const contents = head + preservedTail;\n return { path, contents, action: contents === existing ? \"unchanged\" : \"update\" };\n}\n","/**\n * The text of the code the `js` generator writes.\n *\n * Heads up before reading on: every function here returns *TypeScript source\n * code as a string*. When you see `export const ...` inside quotes, that's\n * the output a user's repo will contain, not this module's own logic.\n * Building output from arrays of lines (rather than nested template strings)\n * keeps the quoting readable; the only escaping left is for code samples\n * inside the generated comments.\n *\n * Three kinds of output are built here:\n * - generatedRegion() the per-tool contract (regenerated freely)\n * - ownedRegionScaffold() the execute() body (written once, then owned)\n * - runtimeSource() / barrelSource() fully-generated support files\n *\n * The contract the output fulfills: read tools work out of the box (a real\n * request to the endpoint), mutation tools start disabled with the working\n * code generated but commented out, and the user-confirmation step for\n * mutations lives in the generated region so it cannot be edited away.\n */\n\nimport { jsonSchemaToTs, pascalCase } from \"../schema.js\";\nimport type { ReviewedTool } from \"../types.js\";\nimport { GENERATED_END, GENERATED_START } from \"./js.js\";\n\n/**\n * Everything above the end-marker of a per-tool file: the parts that must\n * track the API contract exactly: name, description, schema, input type,\n * hints, and the register() wrapper.\n */\nexport function generatedRegion(tool: ReviewedTool): string {\n const pascal = pascalCase(tool.name);\n const camel = lowercaseFirst(pascal);\n const schemaJson = JSON.stringify(tool.inputSchema, null, 2);\n const inputType = jsonSchemaToTs(tool.inputSchema, undefined);\n const mutates = tool.riskTier !== \"safe-read\";\n\n // The imports cover what this file's regions use: the generated register()\n // and the owned execute() scaffold. A developer who replaces the scaffold\n // with their own API client can trim the imports they stop using.\n const runtimeImports = [\n \"getModelContext\",\n ...(mutates ? [\"requestUserConfirmation\"] : []),\n \"callApi\",\n \"toolResult\",\n ...(tool.enabledByDefault ? [] : [\"toolDisabled\"]),\n ].join(\", \");\n\n const registerBody = mutates\n ? [\n ` await modelContext.registerTool(`,\n ` {`,\n ` ...${camel}Tool,`,\n ` execute: async (input) => {`,\n ` // This tool changes things, so the user is always asked first. The`,\n ` // confirmation lives in the generated region: it cannot be edited away.`,\n ` const confirmed = await requestUserConfirmation(`,\n ` ${JSON.stringify(`Allow the agent to: ${tool.description}`)},`,\n ` );`,\n ` if (!confirmed) {`,\n ` return {`,\n ` content: [{ type: \"text\", text: \"The user declined this action.\" }],`,\n ` isError: true,`,\n ` };`,\n ` }`,\n ` // The browser has already validated the agent's input against the schema.`,\n ` return execute${pascal}(input as ${tool.inputTypeName});`,\n ` },`,\n ` },`,\n ` { signal },`,\n ` );`,\n ]\n : [\n ` await modelContext.registerTool(`,\n ` {`,\n ` ...${camel}Tool,`,\n ` // The browser has already validated the agent's input against the schema.`,\n ` execute: (input) => execute${pascal}(input as ${tool.inputTypeName}),`,\n ` },`,\n ` { signal },`,\n ` );`,\n ];\n\n return [\n `import { ${runtimeImports} } from \"./runtime.webmcp\";`,\n ``,\n GENERATED_START,\n `/**`,\n ` * ${tool.description}`,\n ` *`,\n ` * Source: ${tool.source.ref} (${tool.source.kind}). Risk: ${tool.riskTier}.`,\n ` * Starts ${tool.enabledByDefault ? \"enabled\" : \"disabled\"} (see execute${pascal} below).`,\n ` * Regenerate with: npx webmcp-codegen generate`,\n ` */`,\n ``,\n `/** The exact contract advertised to the agent. Derived from the API spec. Do not hand-edit. */`,\n `export const ${camel}InputSchema = ${schemaJson};`,\n ``,\n `/** What \\`execute\\` receives. The browser validates agent input against the schema above. */`,\n `export type ${tool.inputTypeName} = ${inputType};`,\n ``,\n `/** Safety hints computed by webmcp-codegen. Informational metadata for hosts and UIs. */`,\n `export const ${camel}Hints = ${JSON.stringify(tool.hints)} as const;`,\n ``,\n `/** The tool definition, minus \\`execute\\` (which is yours, below the marker). */`,\n `export const ${camel}Tool = {`,\n ` name: ${JSON.stringify(tool.name)},`,\n ` description: ${JSON.stringify(tool.description)},`,\n ` inputSchema: ${camel}InputSchema,`,\n `};`,\n ``,\n `/**`,\n ` * Register this tool with WebMCP. Call it once on page load, or use`,\n ` * registerAllTools() from the generated index.ts.`,\n ` *`,\n ` * Pass an AbortSignal to unregister later: controller.abort().`,\n ` */`,\n `export async function register${pascal}(signal?: AbortSignal): Promise<void> {`,\n ` const modelContext = getModelContext();`,\n ...registerBody,\n `}`,\n ``,\n GENERATED_END,\n ].join(\"\\n\");\n}\n\n/**\n * The scaffold below the marker, written exactly once (when the file is\n * first created). After that the developer owns it and regeneration never\n * touches it. That promise is the whole reason the marker split exists.\n *\n * The scaffold is real code, not a TODO: the spec knows the method, the\n * path, and which fields go where, so the default implementation actually\n * calls the endpoint from the page, with the signed-in user's session.\n * Reads are born working; mutations are born disabled (the working code is\n * right there, commented out, one deliberate edit away from live).\n */\nexport function ownedRegionScaffold(tool: ReviewedTool): string {\n const pascal = pascalCase(tool.name);\n const call = requestCall(tool);\n const lines: string[] = [\n ``,\n `/**`,\n ` * What actually happens when the agent calls \"${tool.name}\".`,\n ` *`,\n ` * Default implementation: calls ${tool.source.ref} from this page, with the`,\n ` * signed-in user's session. Replace it with your app's own API client`,\n ` * whenever you like; the contract above never changes.`,\n ];\n\n if (tool.serverUrl) {\n lines.push(\n ` *`,\n ` * Your spec lists the API at ${tool.serverUrl}. If the app and the API`,\n ` * are on different hosts, pass the full URL to callApi instead.`,\n );\n }\n\n if (tool.riskTier !== \"safe-read\") {\n lines.push(\n ` *`,\n ` * This tool is ${tool.riskTier}: it ${\n tool.riskTier === \"destructive-confirm\" ? \"cannot easily be undone\" : \"changes things\"\n }.`,\n ` * The user is asked to confirm every call (built into the generated region).`,\n );\n }\n lines.push(` */`);\n\n if (tool.piiInOutput.length > 0) {\n lines.push(\n `//`,\n `// ⚠ webmcp-codegen flagged these response fields as likely PII: ${tool.piiInOutput.join(\", \")}.`,\n `// Everything you return reaches the agent. Leave those fields out of what you`,\n `// return unless the agent genuinely needs them, and say so in a comment if you keep them.`,\n );\n }\n\n if (tool.enabledByDefault) {\n lines.push(\n `export async function execute${pascal}(input: ${tool.inputTypeName}) {`,\n ` ${call}`,\n ` return toolResult(data);`,\n `}`,\n );\n } else {\n lines.push(\n `export async function execute${pascal}(input: ${tool.inputTypeName}) {`,\n ` // This tool starts disabled: it ${\n tool.endpointRole === \"endpoint\"\n ? \"changes things\"\n : `wraps an ${tool.endpointRole} endpoint`\n }. Agents can see it, and calling it tells`,\n ` // them it is disabled. To enable it, delete the line below and uncomment the code.`,\n ` return toolDisabled(\"${tool.name}.webmcp.ts\");`,\n ``,\n ` // ${call}`,\n ` // return toolResult(data);`,\n `}`,\n );\n }\n\n return lines.join(\"\\n\");\n}\n\n/**\n * The one working request line inside a scaffold, built from what the spec\n * knows: the path template becomes a template literal, query params become\n * the search string, body fields become the JSON body.\n *\n * \"/pets/{id}\" + DELETE → const data = await callApi(`/pets/${input.id}`, { method: \"DELETE\" });\n *\n * When the source carries no route information, we fall back to an honest\n * TODO instead of inventing a URL.\n */\nfunction requestCall(tool: ReviewedTool): string {\n if (!tool.httpMethod || !tool.pathTemplate || !tool.paramLocations) {\n return `const data = null; // TODO: call your app's existing code here.`;\n }\n\n const { path: pathParams, query: queryParams, body: bodyParams } = tool.paramLocations;\n\n // \"/pets/{id}\" → `/pets/${input.id}`. Params the schema knows by name.\n let pathExpr = `\\`${tool.pathTemplate.replace(/\\{([^}]+)\\}/g, (_m, param: string) => `\\${${inputRef(param)}}`)}\\``;\n if (pathParams.length === 0) pathExpr = JSON.stringify(tool.pathTemplate);\n\n const options: string[] = [`method: ${JSON.stringify(tool.httpMethod)}`];\n if (queryParams.length > 0) {\n const entries = queryParams.map((name) => `${safeKey(name)}: ${inputRef(name)}`).join(\", \");\n options.push(`query: { ${entries} }`);\n }\n if (bodyParams.length > 0) {\n if (bodyParams.length === 1 && bodyParams[0] === \"body\") {\n // A non-object request body arrives as a single \"body\" field.\n options.push(`body: input.body`);\n } else {\n const entries = bodyParams.map((name) => `${safeKey(name)}: ${inputRef(name)}`).join(\", \");\n options.push(`body: { ${entries} }`);\n }\n }\n\n return `const data = await callApi(${pathExpr}, { ${options.join(\", \")} });`;\n}\n\n/**\n * How generated code reads a field off `input`. Dot access for identifier\n * names (\"input.limit\"), bracket access for the rest (\"input[\"pet-id\"]\").\n */\nfunction inputRef(name: string): string {\n return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)\n ? `input.${name}`\n : `input[${JSON.stringify(name)}]`;\n}\n\n/** Quote an object key only when it needs it. */\nfunction safeKey(name: string): string {\n return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);\n}\n\n/**\n * The shared runtime: the minimal WebMCP browser types plus the helpers the\n * generated files use. Kept tiny on purpose: this is the only browser\n * coupling in the output.\n */\nexport function runtimeSource(): string {\n return `/**\n * Generated by webmcp-codegen. This file is fully regenerated on every run.\n * Do not edit by hand; your changes will be lost.\n */\n\n/** The result shape tools return (same as MCP tool results). */\nexport interface WebMcpToolResult {\n content: { type: \"text\"; text: string }[];\n isError?: boolean;\n [key: string]: unknown;\n}\n\n/** A tool as the browser runtime understands it. */\nexport interface WebMcpToolDefinition {\n name: string;\n description: string;\n inputSchema?: Record<string, unknown>;\n execute: (input: Record<string, unknown>) => unknown | Promise<unknown>;\n}\n\n/** The slice of the WebMCP draft spec the generated code uses. */\nexport interface ModelContext {\n registerTool(\n tool: WebMcpToolDefinition,\n options?: { signal?: AbortSignal },\n ): Promise<void>;\n}\n\n/**\n * Access the page's WebMCP model context, with a helpful error when the\n * browser doesn't have one (rather than an undefined-callsite mystery).\n */\nexport function getModelContext(): ModelContext {\n const modelContext = (document as unknown as { modelContext?: ModelContext }).modelContext;\n if (!modelContext) {\n throw new Error(\n \"WebMCP is not available in this browser. \" +\n \"Enable chrome://flags/#enable-webmcp-testing (Chrome 146+), \" +\n \"or add the WebMCP polyfill to your app.\",\n );\n }\n return modelContext;\n}\n\n/**\n * Call your API from the page. Same origin by default (pass a full URL when\n * the API lives on another host), always with the signed-in user's session\n * cookies. Throws on HTTP errors; returns the parsed JSON body, or raw text\n * when the response is not JSON.\n */\nexport async function callApi(\n path: string,\n options: { method?: string; query?: Record<string, unknown>; body?: unknown } = {},\n): Promise<unknown> {\n const url = new URL(path, window.location.origin);\n for (const [key, value] of Object.entries(options.query ?? {})) {\n if (value !== undefined && value !== null) url.searchParams.set(key, String(value));\n }\n const response = await fetch(url, {\n method: options.method ?? \"GET\",\n credentials: \"include\",\n headers: options.body !== undefined ? { \"content-type\": \"application/json\" } : undefined,\n body: options.body !== undefined ? JSON.stringify(options.body) : undefined,\n });\n if (!response.ok) {\n throw new Error(\"Request failed: \" + response.status + \" \" + response.statusText);\n }\n if (response.status === 204) return null;\n const text = await response.text();\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** Wrap a result in the MCP shape, so tool bodies stay one line. */\nexport function toolResult(data: unknown): WebMcpToolResult {\n return {\n content: [\n { type: \"text\", text: typeof data === \"string\" ? data : JSON.stringify(data, null, 2) },\n ],\n };\n}\n\n/**\n * What a disabled tool tells the agent. The tool stays visible (so the agent\n * knows it exists and can ask the human to enable it) but does nothing.\n */\nexport function toolDisabled(fileName: string): WebMcpToolResult {\n return {\n content: [\n {\n type: \"text\",\n text:\n \"This tool is currently disabled by the app developer. Ask them to enable it \" +\n \"(uncomment the implementation in \" + fileName + \").\",\n },\n ],\n isError: true,\n };\n}\n\n/**\n * Default \"agent proposes, human confirms\" gate for write/destructive tools.\n * Deliberately minimal (window.confirm). Replace it with your app's own\n * dialog when you outgrow it. The point is that the user always gets a say.\n */\nexport function requestUserConfirmation(message: string): Promise<boolean> {\n return Promise.resolve(window.confirm(message));\n}\n`;\n}\n\n/** The barrel: one import that registers every generated tool. */\nexport function barrelSource(tools: ReviewedTool[]): string {\n const imports = tools\n .map((tool) => `import { register${pascalCase(tool.name)} } from \"./${tool.name}.webmcp\";`)\n .join(\"\\n\");\n const names = tools.map((tool) => `register${pascalCase(tool.name)}`).join(\",\\n \");\n\n return `/**\n * Generated by webmcp-codegen. This file is fully regenerated on every run.\n * Import registerAllTools() once at app startup:\n *\n * import { registerAllTools } from \"./webmcp\";\n * await registerAllTools();\n */\n\n${imports}\n\nconst registrations = [\n ${names}\n];\n\n/**\n * Register every generated tool with WebMCP. One tool failing (for example\n * because the page's Permissions-Policy disables tools) never takes the\n * others down with it. The failure is logged and registration continues.\n */\nexport async function registerAllTools(signal?: AbortSignal): Promise<void> {\n for (const register of registrations) {\n try {\n await register(signal);\n } catch (error) {\n console.warn(\"[webmcp-codegen] a tool failed to register:\", error);\n }\n }\n}\n`;\n}\n\n/** \"GetOrderStatus\" → \"getOrderStatus\" (for the generated const names). */\nfunction lowercaseFirst(pascal: string): string {\n return pascal.charAt(0).toLowerCase() + pascal.slice(1);\n}\n"],"mappings":"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","names":[]}