webmcp-codegen 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,82 @@
1
1
  # webmcp-codegen
2
2
 
3
- Generate safe, typed, human-reviewed [WebMCP](https://github.com/webmachinelearning/webmcp) tools from the API contracts you already have — OpenAPI specs, tRPC routers, Zod schemas — instead of hand-writing `registerTool()` calls for every action.
3
+ Generate safe, typed, human-reviewed [WebMCP](https://github.com/webmachinelearning/webmcp) tools from the API contracts you already have — instead of hand-writing `registerTool()` calls for every action.
4
4
 
5
5
  > Generate the tool. Review the tool. You own the tool.
6
6
 
7
- **Status: under active development.** This package is published as a placeholder to claim the name; the CLI and adapters are being built in the open. Design document: [`notes/webmcp-codegen-design.md`](https://github.com/SouravInsights/groundstate/blob/main/notes/webmcp-codegen-design.md).
8
-
9
- ## What it will do
7
+ ## Quick start
10
8
 
11
9
  ```bash
12
- npx webmcp-codegen init # detect your API layer, scaffold config
13
- npx webmcp-codegen generate # generate reviewed, safety-classified tool files
10
+ npm install -D webmcp-codegen
11
+
12
+ npx webmcp-codegen init # detects your OpenAPI spec, writes codegen.config.mjs
13
+ npx webmcp-codegen generate # generates reviewed tool files into ./src/webmcp
14
+ ```
15
+
16
+ Then implement each `execute()` (below the marker — it's yours, regeneration never touches it) and register everything at app startup:
17
+
18
+ ```ts
19
+ import { registerAllTools } from "./webmcp";
20
+
21
+ await registerAllTools();
14
22
  ```
15
23
 
16
- - Real TypeScript files in your repo — readable, editable, no runtime magic
17
- - Schemas derived from your existing types, never hand-typed twice
18
- - Automatic risk classification (`readOnlyHint`, `destructiveHint`), PII field detection, and an audit pass built for CI
19
- - Regeneration that never clobbers your hand-written logic
24
+ ## What you get
25
+
26
+ For every operation in your spec, one file like `get-order-status.webmcp.ts`:
27
+
28
+ ```ts
29
+ // ─── webmcp-codegen: generated — do not edit this region ───
30
+ export const getOrderStatusInputSchema = { /* derived from your spec */ };
31
+ export type GetOrderStatusInput = { orderId: string };
32
+ export const getOrderStatusTool = { name: "get-order-status", /* ... */ };
33
+ export async function registerGetOrderStatus(signal?: AbortSignal) { /* ... */ }
34
+ // ─── webmcp-codegen: end generated — your code below survives regeneration ───
35
+
36
+ export async function executeGetOrderStatus(input: GetOrderStatusInput) {
37
+ // You own this. Regeneration never touches it.
38
+ }
39
+ ```
40
+
41
+ - **Real files in your repo** — readable, editable, no runtime magic
42
+ - **Schemas derived from your spec**, never hand-typed twice; `$ref`s fully resolved
43
+ - **Safety classification on every tool** — read/write/destructive from the HTTP verb and naming heuristics, with `readOnlyHint`/`destructiveHint`/`idempotentHint` computed for you
44
+ - **An audit pass built into `generate`** — PII-in-response warnings, agent-instructing description linting, auth-boundary checks; errors block generation (like `npm audit`, with exit codes for CI)
45
+ - **Regeneration never clobbers your code** — contracts regenerate, your `execute()` survives; hand-edited generated regions produce a `.new` file instead of a conflict
46
+
47
+ ## CLI
48
+
49
+ | Command | What it does |
50
+ |---|---|
51
+ | `webmcp-codegen init` | Detect your spec, write `codegen.config.mjs` |
52
+ | `webmcp-codegen generate` | Generate/update tool files (audit runs by default) |
53
+ | `generate --dry-run` | Preview everything, write nothing |
54
+ | `generate --watch` | Re-generate when source files change |
55
+ | `generate --force` | Write files even when the audit reports errors |
56
+ | `generate --skip-audit` | Skip the audit pass |
57
+
58
+ ## Config
59
+
60
+ ```js
61
+ // codegen.config.mjs
62
+ import { defineConfig } from "webmcp-codegen";
63
+ import { openapi } from "webmcp-codegen/sources";
64
+ import { js } from "webmcp-codegen/generators";
65
+
66
+ export default defineConfig({
67
+ sources: [openapi({ spec: "./openapi.yaml" })],
68
+ generate: [js({ outDir: "./src/webmcp" })],
69
+ safety: {
70
+ piiFields: ["internalId"], // extend the built-in PII heuristics
71
+ exclude: ["internal"], // skip tools by name or route substring
72
+ },
73
+ });
74
+ ```
75
+
76
+ ## Requirements
77
+
78
+ - Node.js ≥ 20
79
+ - To *use* the generated tools in a browser: Chrome 146+ with `#enable-webmcp-testing` (or the WebMCP polyfill)
20
80
 
21
81
  ## License
22
82
 
@@ -0,0 +1,109 @@
1
+ // src/schema.ts
2
+ function resolveLocalRef(spec, ref) {
3
+ if (!ref.startsWith("#/")) {
4
+ throw new Error(
5
+ `Cannot resolve external $ref "${ref}". Only local refs (starting with "#/") are supported \u2014 bundle the spec first if it is split across files.`
6
+ );
7
+ }
8
+ let node = spec;
9
+ for (const segment of ref.slice(2).split("/")) {
10
+ const key = segment.replace(/~1/g, "/").replace(/~0/g, "~");
11
+ if (node === null || typeof node !== "object" || !(key in node)) {
12
+ throw new Error(`$ref "${ref}" does not point at anything in this spec (missing "${key}").`);
13
+ }
14
+ node = node[key];
15
+ }
16
+ return node;
17
+ }
18
+ function deref(schema, spec) {
19
+ const ref = schema.$ref;
20
+ if (typeof ref !== "string") return schema;
21
+ const target = resolveLocalRef(spec, ref);
22
+ const { $ref: _ignored, ...siblings } = schema;
23
+ return { ...target, ...siblings };
24
+ }
25
+ function deepDeref(schema, spec, ancestorRefs = /* @__PURE__ */ new Set()) {
26
+ const ref = schema.$ref;
27
+ if (typeof ref === "string") {
28
+ if (ancestorRefs.has(ref)) {
29
+ return {
30
+ type: "object",
31
+ description: `Recursive reference to ${ref} (resolved once to keep the schema finite).`
32
+ };
33
+ }
34
+ const target = resolveLocalRef(spec, ref);
35
+ return deepDeref(target, spec, new Set(ancestorRefs).add(ref));
36
+ }
37
+ const out = { ...schema };
38
+ if (out.properties) {
39
+ out.properties = Object.fromEntries(
40
+ Object.entries(out.properties).map(([key, value]) => [
41
+ key,
42
+ deepDeref(value, spec, ancestorRefs)
43
+ ])
44
+ );
45
+ }
46
+ if (out.items) out.items = deepDeref(out.items, spec, ancestorRefs);
47
+ for (const unionKeyword of ["anyOf", "oneOf", "allOf"]) {
48
+ const variants = out[unionKeyword];
49
+ if (variants) {
50
+ out[unionKeyword] = variants.map((variant) => deepDeref(variant, spec, ancestorRefs));
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function jsonSchemaToTs(schema, spec) {
56
+ const node = deref(schema, spec);
57
+ if (node.enum && Array.isArray(node.enum)) {
58
+ return node.enum.map((value) => JSON.stringify(value)).join(" | ");
59
+ }
60
+ if (node.anyOf || node.oneOf) {
61
+ const variants = node.anyOf ?? node.oneOf;
62
+ return variants.map((variant) => jsonSchemaToTs(variant, spec)).join(" | ");
63
+ }
64
+ if (node.allOf) {
65
+ return node.allOf.map((part) => jsonSchemaToTs(part, spec)).join(" & ");
66
+ }
67
+ switch (node.type) {
68
+ case "string":
69
+ return "string";
70
+ case "number":
71
+ case "integer":
72
+ return "number";
73
+ case "boolean":
74
+ return "boolean";
75
+ case "null":
76
+ return "null";
77
+ case "array": {
78
+ const items = node.items ? jsonSchemaToTs(node.items, spec) : "unknown";
79
+ return items.includes("|") ? `Array<${items}>` : `${items}[]`;
80
+ }
81
+ case "object":
82
+ case void 0: {
83
+ const properties = node.properties;
84
+ if (!properties || Object.keys(properties).length === 0) {
85
+ return "Record<string, unknown>";
86
+ }
87
+ const required = new Set(node.required ?? []);
88
+ const fields = Object.entries(properties).map(([key, fieldSchema]) => {
89
+ const optional = required.has(key) ? "" : "?";
90
+ const nullable = fieldSchema.nullable ? " | null" : "";
91
+ return `${JSON.stringify(key)}${optional}: ${jsonSchemaToTs(fieldSchema, spec)}${nullable}`;
92
+ });
93
+ return `{ ${fields.join("; ")} }`;
94
+ }
95
+ default:
96
+ return "unknown /* TODO: webmcp-codegen could not express this schema \u2014 tighten it by hand */";
97
+ }
98
+ }
99
+ function pascalCase(name) {
100
+ return name.split(/[-_]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
101
+ }
102
+
103
+ export {
104
+ deref,
105
+ deepDeref,
106
+ jsonSchemaToTs,
107
+ pascalCase
108
+ };
109
+ //# sourceMappingURL=chunk-5L4KN6F4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/schema.ts"],"sourcesContent":["/**\n * JSON Schema helpers: resolving `$ref` pointers and turning a schema into\n * TypeScript source text for the generated input types.\n *\n * Scope, deliberately small:\n * - Only *local* refs (`#/components/schemas/...`) are resolved. External\n * refs (other files or URLs) produce a clear error instead of a silent\n * wrong answer. This covers the overwhelming majority of hand-written and\n * framework-emitted OpenAPI specs.\n * - The TypeScript printer covers the shapes REST APIs actually use:\n * objects with required/optional fields, arrays, enums, primitives, and\n * `anyOf`/`oneOf` unions. Anything more exotic becomes `unknown` with a\n * TODO comment rather than a plausible-looking lie.\n */\n\nimport type { JsonSchema } from \"./types.js\";\n\n/**\n * Resolve a local `$ref` like \"#/components/schemas/Order\" against the\n * parsed spec document. Throws a clear error for external refs.\n */\nexport function resolveLocalRef(spec: unknown, ref: string): unknown {\n if (!ref.startsWith(\"#/\")) {\n throw new Error(\n `Cannot resolve external $ref \"${ref}\". ` +\n `Only local refs (starting with \"#/\") are supported — bundle the spec first if it is split across files.`,\n );\n }\n let node: unknown = spec;\n for (const segment of ref.slice(2).split(\"/\")) {\n // JSON Pointer escapes: \"~1\" is \"/\", \"~0\" is \"~\"\n const key = segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\");\n if (node === null || typeof node !== \"object\" || !(key in node)) {\n throw new Error(`$ref \"${ref}\" does not point at anything in this spec (missing \"${key}\").`);\n }\n node = (node as Record<string, unknown>)[key];\n }\n return node;\n}\n\n/**\n * If `schema` is a `$ref`, resolve it (one level — recursion happens as the\n * caller walks the tree). Sibling keywords next to `$ref` are merged over\n * the resolved schema, which is what OpenAPI 3.1 semantics require.\n */\nexport function deref(schema: JsonSchema, spec: unknown): JsonSchema {\n const ref = schema.$ref;\n if (typeof ref !== \"string\") return schema;\n const target = resolveLocalRef(spec, ref) as JsonSchema;\n const { $ref: _ignored, ...siblings } = schema;\n return { ...target, ...siblings };\n}\n\n/**\n * Resolve `$ref`s at every depth, so the generated `inputSchema` is a\n * self-contained JSON Schema — the browser has no idea what\n * \"#/components/schemas/Order\" means, so refs must not survive codegen.\n *\n * Recursive models (Order → LineItem → Order) would loop forever, so a ref\n * that points back to one of its own ancestors resolves to a plain object\n * with a note instead. The tool schema stays finite and honest.\n */\nexport function deepDeref(\n schema: JsonSchema,\n spec: unknown,\n ancestorRefs: Set<string> = new Set(),\n): JsonSchema {\n const ref = schema.$ref;\n if (typeof ref === \"string\") {\n if (ancestorRefs.has(ref)) {\n return {\n type: \"object\",\n description: `Recursive reference to ${ref} (resolved once to keep the schema finite).`,\n };\n }\n const target = resolveLocalRef(spec, ref) as JsonSchema;\n return deepDeref(target, spec, new Set(ancestorRefs).add(ref));\n }\n\n const out: JsonSchema = { ...schema };\n if (out.properties) {\n out.properties = Object.fromEntries(\n Object.entries(out.properties).map(([key, value]) => [\n key,\n deepDeref(value, spec, ancestorRefs),\n ]),\n );\n }\n if (out.items) out.items = deepDeref(out.items, spec, ancestorRefs);\n for (const unionKeyword of [\"anyOf\", \"oneOf\", \"allOf\"] as const) {\n const variants = out[unionKeyword];\n if (variants) {\n out[unionKeyword] = variants.map((variant) => deepDeref(variant, spec, ancestorRefs));\n }\n }\n return out;\n}\n\n/**\n * Print a JSON Schema as TypeScript type source, e.g. for the generated\n * `GetOrderStatusInput` interface. `spec` is the root document, needed to\n * resolve any `$ref`s encountered along the way.\n */\nexport function jsonSchemaToTs(schema: JsonSchema, spec: unknown): string {\n const node = deref(schema, spec);\n\n if (node.enum && Array.isArray(node.enum)) {\n return node.enum.map((value) => JSON.stringify(value)).join(\" | \");\n }\n\n if (node.anyOf || node.oneOf) {\n const variants = (node.anyOf ?? node.oneOf) as JsonSchema[];\n return variants.map((variant) => jsonSchemaToTs(variant, spec)).join(\" | \");\n }\n\n if (node.allOf) {\n return node.allOf.map((part) => jsonSchemaToTs(part, spec)).join(\" & \");\n }\n\n switch (node.type) {\n case \"string\":\n return \"string\";\n case \"number\":\n case \"integer\":\n return \"number\";\n case \"boolean\":\n return \"boolean\";\n case \"null\":\n return \"null\";\n case \"array\": {\n const items = node.items ? jsonSchemaToTs(node.items, spec) : \"unknown\";\n // Wrap unions so `string | number[]` doesn't silently change meaning.\n return items.includes(\"|\") ? `Array<${items}>` : `${items}[]`;\n }\n case \"object\":\n case undefined: {\n // Schemas without an explicit \"type\" but with \"properties\" are objects.\n const properties = node.properties;\n if (!properties || Object.keys(properties).length === 0) {\n return \"Record<string, unknown>\";\n }\n const required = new Set(node.required ?? []);\n const fields = Object.entries(properties).map(([key, fieldSchema]) => {\n const optional = required.has(key) ? \"\" : \"?\";\n const nullable = fieldSchema.nullable ? \" | null\" : \"\";\n return `${JSON.stringify(key)}${optional}: ${jsonSchemaToTs(fieldSchema, spec)}${nullable}`;\n });\n return `{ ${fields.join(\"; \")} }`;\n }\n default:\n return \"unknown /* TODO: webmcp-codegen could not express this schema — tighten it by hand */\";\n }\n}\n\n/** \"get-order-status\" → \"GetOrderStatus\" (for generated type names). */\nexport function pascalCase(name: string): string {\n return name\n .split(/[-_]/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(\"\");\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,39 @@
1
+ // src/naming.ts
2
+ function toToolName(raw) {
3
+ const name = raw.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[{}/_\s.]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().replace(/-+/g, "-").replace(/^-+|-+$/g, "");
4
+ const prefixed = /^[a-zA-Z]/.test(name) ? name : `tool-${name}`;
5
+ return prefixed || "unnamed-tool";
6
+ }
7
+ function nameFromRoute(method, path) {
8
+ return toToolName(`${method.toLowerCase()} ${path}`);
9
+ }
10
+ function dedupeNames(candidates) {
11
+ const seen = /* @__PURE__ */ new Set();
12
+ const names = [];
13
+ const renames = [];
14
+ for (const candidate of candidates) {
15
+ let name = candidate.name;
16
+ if (seen.has(name) && candidate.httpMethod) {
17
+ name = `${name}-${candidate.httpMethod.toLowerCase()}`;
18
+ }
19
+ let counter = 2;
20
+ const base = name;
21
+ while (seen.has(name)) {
22
+ name = `${base}-${counter}`;
23
+ counter += 1;
24
+ }
25
+ if (name !== candidate.name) {
26
+ renames.push({ from: candidate.name, to: name });
27
+ }
28
+ seen.add(name);
29
+ names.push(name);
30
+ }
31
+ return { names, renames };
32
+ }
33
+
34
+ export {
35
+ toToolName,
36
+ nameFromRoute,
37
+ dedupeNames
38
+ };
39
+ //# sourceMappingURL=chunk-BIKKPCRT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/naming.ts"],"sourcesContent":["/**\n * Tool naming.\n *\n * Tool names are the vocabulary an agent reasons over, so we make them\n * boring and predictable: kebab-case, derived from the operationId when the\n * source has one, and always matching the character set the WebMCP runtime\n * accepts (the same rule the groundstate core registry enforces).\n */\n\n/** The character set the WebMCP runtime accepts for tool names. */\nexport const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;\n\n/**\n * Turn an operationId or route into a valid, readable tool name.\n *\n * Examples:\n * \"getOrderStatus\" → \"get-order-status\"\n * \"GET /orders/{id}\" → \"get-orders-id\"\n * \"list_pets\" → \"list-pets\"\n */\nexport function toToolName(raw: string): string {\n const name = raw\n // Split acronym boundaries first: \"getHTTPStatus\" → \"get-HTTPStatus\"\n .replace(/([A-Z]+)([A-Z][a-z])/g, \"$1-$2\")\n // Then camelCase and PascalCase boundaries: \"getOrder\" → \"get-Order\"\n .replace(/([a-z0-9])([A-Z])/g, \"$1-$2\")\n // Path placeholders and separators become dashes: \"/orders/{id}\" → \"-orders-id\"\n .replace(/[{}/_\\s.]+/g, \"-\")\n // Anything left that isn't a letter, digit or dash is dropped\n .replace(/[^a-zA-Z0-9-]/g, \"\")\n .toLowerCase()\n // Collapse and trim dashes\n .replace(/-+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n\n // A name must start with a letter. If it doesn't (e.g. it came from a bare\n // numeric path), give it a neutral prefix rather than failing.\n const prefixed = /^[a-zA-Z]/.test(name) ? name : `tool-${name}`;\n return prefixed || \"unnamed-tool\";\n}\n\n/**\n * Build the fallback name for an operation that has no operationId:\n * the HTTP method plus the path, e.g. GET /orders/{id} → \"get-orders-id\".\n */\nexport function nameFromRoute(method: string, path: string): string {\n return toToolName(`${method.toLowerCase()} ${path}`);\n}\n\n/**\n * Make every name unique. When two operations slugify to the same name we\n * append the HTTP method (\"get-order-status-post\" would be worse); when that\n * still collides we append a counter. Returns the final names plus a list of\n * renames so the audit report can show them.\n */\nexport function dedupeNames(candidates: { name: string; httpMethod?: string }[]): {\n names: string[];\n renames: { from: string; to: string }[];\n} {\n const seen = new Set<string>();\n const names: string[] = [];\n const renames: { from: string; to: string }[] = [];\n\n for (const candidate of candidates) {\n let name = candidate.name;\n if (seen.has(name) && candidate.httpMethod) {\n name = `${name}-${candidate.httpMethod.toLowerCase()}`;\n }\n let counter = 2;\n const base = name;\n while (seen.has(name)) {\n name = `${base}-${counter}`;\n counter += 1;\n }\n if (name !== candidate.name) {\n renames.push({ from: candidate.name, to: name });\n }\n seen.add(name);\n names.push(name);\n }\n\n return { names, renames };\n}\n"],"mappings":";AAoBO,SAAS,WAAW,KAAqB;AAC9C,QAAM,OAAO,IAEV,QAAQ,yBAAyB,OAAO,EAExC,QAAQ,sBAAsB,OAAO,EAErC,QAAQ,eAAe,GAAG,EAE1B,QAAQ,kBAAkB,EAAE,EAC5B,YAAY,EAEZ,QAAQ,OAAO,GAAG,EAClB,QAAQ,YAAY,EAAE;AAIzB,QAAM,WAAW,YAAY,KAAK,IAAI,IAAI,OAAO,QAAQ,IAAI;AAC7D,SAAO,YAAY;AACrB;AAMO,SAAS,cAAc,QAAgB,MAAsB;AAClE,SAAO,WAAW,GAAG,OAAO,YAAY,CAAC,IAAI,IAAI,EAAE;AACrD;AAQO,SAAS,YAAY,YAG1B;AACA,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,QAAkB,CAAC;AACzB,QAAM,UAA0C,CAAC;AAEjD,aAAW,aAAa,YAAY;AAClC,QAAI,OAAO,UAAU;AACrB,QAAI,KAAK,IAAI,IAAI,KAAK,UAAU,YAAY;AAC1C,aAAO,GAAG,IAAI,IAAI,UAAU,WAAW,YAAY,CAAC;AAAA,IACtD;AACA,QAAI,UAAU;AACd,UAAM,OAAO;AACb,WAAO,KAAK,IAAI,IAAI,GAAG;AACrB,aAAO,GAAG,IAAI,IAAI,OAAO;AACzB,iBAAW;AAAA,IACb;AACA,QAAI,SAAS,UAAU,MAAM;AAC3B,cAAQ,KAAK,EAAE,MAAM,UAAU,MAAM,IAAI,KAAK,CAAC;AAAA,IACjD;AACA,SAAK,IAAI,IAAI;AACb,UAAM,KAAK,IAAI;AAAA,EACjB;AAEA,SAAO,EAAE,OAAO,QAAQ;AAC1B;","names":[]}
@@ -0,0 +1,237 @@
1
+ import {
2
+ dedupeNames
3
+ } from "./chunk-BIKKPCRT.js";
4
+ import {
5
+ pascalCase
6
+ } from "./chunk-5L4KN6F4.js";
7
+
8
+ // src/config.ts
9
+ import { access } from "fs/promises";
10
+ import { join, resolve } from "path";
11
+ import { pathToFileURL } from "url";
12
+ function defineConfig(config) {
13
+ return config;
14
+ }
15
+ var CONFIG_FILE_NAMES = ["codegen.config.mjs", "codegen.config.js"];
16
+ async function loadConfig(cwd, explicitPath) {
17
+ const candidates = explicitPath ? [resolve(cwd, explicitPath)] : CONFIG_FILE_NAMES.map((name) => join(cwd, name));
18
+ for (const candidate of candidates) {
19
+ if (!await exists(candidate)) continue;
20
+ const module = await import(pathToFileURL(candidate).href);
21
+ const config = module.default;
22
+ if (!isCodegenConfig(config)) {
23
+ throw new Error(
24
+ `${candidate} must default-export defineConfig({ sources: [...], generate: [...] }).`
25
+ );
26
+ }
27
+ return { config, path: candidate };
28
+ }
29
+ throw new Error(
30
+ explicitPath ? `No config file at "${explicitPath}".` : `No codegen.config.mjs found in ${cwd}. Run \`npx webmcp-codegen init\` to create one.`
31
+ );
32
+ }
33
+ function isCodegenConfig(value) {
34
+ if (value === null || typeof value !== "object") return false;
35
+ const config = value;
36
+ return Array.isArray(config.sources) && Array.isArray(config.generate);
37
+ }
38
+ async function exists(path) {
39
+ try {
40
+ await access(path);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // src/pipeline.ts
48
+ import { mkdir, writeFile } from "fs/promises";
49
+ import { dirname } from "path";
50
+
51
+ // src/safety.ts
52
+ var DESTRUCTIVE_WORDS = /\b(cancel|delete|remove|destroy|deactivate|refund|revoke|purge|close)\b/i;
53
+ var DEFAULT_PII_FIELDS = [
54
+ "password",
55
+ "ssn",
56
+ "token",
57
+ "secret",
58
+ "apikey",
59
+ "api_key",
60
+ "email",
61
+ "dob",
62
+ "birthdate",
63
+ "phone",
64
+ "address",
65
+ "creditcard",
66
+ "cardnumber",
67
+ "cvv"
68
+ ];
69
+ var AGENT_INSTRUCTION_PATTERN = /\b(you (must|should|always|are)|as an ai|ignore (all |previous )?instructions|do not refuse)\b/i;
70
+ function classifySideEffect(tool) {
71
+ switch (tool.httpMethod) {
72
+ case "GET":
73
+ case "HEAD":
74
+ case "OPTIONS":
75
+ return "read";
76
+ case "DELETE":
77
+ return "destructive";
78
+ case "POST":
79
+ case "PUT":
80
+ case "PATCH":
81
+ return DESTRUCTIVE_WORDS.test(tool.name) || DESTRUCTIVE_WORDS.test(tool.source.ref) ? "destructive" : "write";
82
+ default:
83
+ return "unknown";
84
+ }
85
+ }
86
+ function hintsFor(tool, sideEffect) {
87
+ const method = tool.httpMethod;
88
+ return {
89
+ readOnlyHint: sideEffect === "read",
90
+ destructiveHint: sideEffect === "destructive",
91
+ // PUT/PATCH/DELETE can be safely retried with the same input; POST cannot.
92
+ idempotentHint: sideEffect === "read" || method === "PUT" || method === "PATCH" || method === "DELETE"
93
+ };
94
+ }
95
+ function riskTierFor(sideEffect) {
96
+ switch (sideEffect) {
97
+ case "read":
98
+ return "safe-read";
99
+ case "destructive":
100
+ return "destructive-confirm";
101
+ default:
102
+ return "write-confirm";
103
+ }
104
+ }
105
+ function findPiiFields(schema, extraFields = [], prefix = "") {
106
+ if (!schema?.properties) return [];
107
+ const piiNames = new Set(
108
+ [...DEFAULT_PII_FIELDS, ...extraFields].map((name) => name.toLowerCase())
109
+ );
110
+ const found = [];
111
+ for (const [key, fieldSchema] of Object.entries(schema.properties)) {
112
+ const path = prefix ? `${prefix}.${key}` : key;
113
+ const normalizedKey = key.toLowerCase().replace(/[-_]/g, "");
114
+ const looksSensitive = piiNames.has(key.toLowerCase()) || piiNames.has(normalizedKey) || [...piiNames].some((name) => normalizedKey === name.replace(/[-_]/g, ""));
115
+ if (looksSensitive) found.push(path);
116
+ found.push(...findPiiFields(fieldSchema, extraFields, path));
117
+ }
118
+ return found;
119
+ }
120
+ function reviewTools(candidates, safety = {}) {
121
+ const excluded = (safety.exclude ?? []).map((pattern) => pattern.toLowerCase());
122
+ return candidates.filter(
123
+ (tool) => !excluded.some(
124
+ (pattern) => tool.name.toLowerCase().includes(pattern) || tool.source.ref.toLowerCase().includes(pattern)
125
+ )
126
+ ).map((tool) => {
127
+ const sideEffect = classifySideEffect(tool);
128
+ return {
129
+ ...tool,
130
+ sideEffect,
131
+ riskTier: riskTierFor(sideEffect),
132
+ hints: hintsFor(tool, sideEffect),
133
+ piiInOutput: findPiiFields(tool.outputSchema, safety.piiFields)
134
+ };
135
+ });
136
+ }
137
+ function auditTools(tools, renames = []) {
138
+ const findings = [];
139
+ for (const rename of renames) {
140
+ findings.push({
141
+ level: "warning",
142
+ tool: rename.to,
143
+ message: `Renamed "${rename.from}" \u2192 "${rename.to}" to keep tool names unique.`
144
+ });
145
+ }
146
+ for (const tool of tools) {
147
+ if (!tool.description || tool.description.trim().length === 0) {
148
+ findings.push({
149
+ level: "error",
150
+ tool: tool.name,
151
+ message: "No description. Agents pick tools by description \u2014 this tool is invisible."
152
+ });
153
+ continue;
154
+ }
155
+ if (tool.descriptionSource === "generated-template") {
156
+ findings.push({
157
+ level: "warning",
158
+ 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.`
160
+ });
161
+ }
162
+ if (AGENT_INSTRUCTION_PATTERN.test(tool.description)) {
163
+ findings.push({
164
+ level: "warning",
165
+ tool: tool.name,
166
+ message: "The description reads like instructions to the agent, not a description of the tool. Describe what the tool does; never try to steer the agent from here."
167
+ });
168
+ }
169
+ if (tool.riskTier === "safe-read" && DESTRUCTIVE_WORDS.test(tool.name)) {
170
+ findings.push({
171
+ level: "error",
172
+ 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.`
174
+ });
175
+ }
176
+ if (tool.piiInOutput.length > 0) {
177
+ findings.push({
178
+ level: "warning",
179
+ 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.`
181
+ });
182
+ }
183
+ if (tool.requiresAuth && tool.riskTier !== "safe-read") {
184
+ findings.push({
185
+ level: "warning",
186
+ 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."
188
+ });
189
+ }
190
+ }
191
+ return findings;
192
+ }
193
+
194
+ // src/pipeline.ts
195
+ async function runGenerate(config, options) {
196
+ 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) => {
199
+ const name = names[index] ?? candidate.name;
200
+ return { ...candidate, name, inputTypeName: `${pascalCase(name)}Input` };
201
+ });
202
+ const tools = reviewTools(named, config.safety);
203
+ const findings = options.skipAudit ? [] : auditTools(tools, renames);
204
+ const errors = findings.filter((finding) => finding.level === "error");
205
+ const blocked = errors.length > 0 && !options.force && !options.skipAudit;
206
+ if (blocked) {
207
+ return { tools, findings, files: [], blocked, wrote: false };
208
+ }
209
+ const files = [];
210
+ for (const generator of config.generate) {
211
+ files.push(...await generator.generate(tools, options.cwd));
212
+ }
213
+ let wrote = false;
214
+ if (!options.dryRun) {
215
+ for (const file of files) {
216
+ if (file.action === "unchanged" && !file.conflict) continue;
217
+ const target = file.conflict ?? file.path;
218
+ await mkdir(dirname(target), { recursive: true });
219
+ await writeFile(target, file.conflict ? conflictContents(file) : file.contents);
220
+ }
221
+ wrote = true;
222
+ }
223
+ return { tools, findings, files, blocked, wrote };
224
+ }
225
+ function conflictContents(file) {
226
+ return `// webmcp-codegen could not regenerate ${file.path} because its generated
227
+ // region was edited by hand. Review this version, then merge it manually.
228
+
229
+ ` + file.contents;
230
+ }
231
+
232
+ export {
233
+ defineConfig,
234
+ loadConfig,
235
+ runGenerate
236
+ };
237
+ //# sourceMappingURL=chunk-NFZ5FMDO.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\nconst 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 — 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 } from \"./naming.js\";\nimport { auditTools, reviewTools } from \"./safety.js\";\nimport { pascalCase } from \"./schema.js\";\nimport type { AuditFinding, CodegenConfig, GeneratedFile, ReviewedTool } 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\nexport interface GenerateResult {\n tools: ReviewedTool[];\n findings: AuditFinding[];\n files: GeneratedFile[];\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 // 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: make names unique before anything downstream sees them.\n // The input type name is derived from the *final* name so they never drift.\n const { names, renames } = dedupeNames(candidates);\n const named = candidates.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 config exclusions.\n const tools = reviewTools(named, config.safety);\n\n // 4. 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, findings, files: [], blocked, wrote: false };\n }\n\n // 5. 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, findings, files, 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 JsonSchema,\n ReviewedTool,\n RiskTier,\n SafetyOptions,\n SideEffect,\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 * 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 — 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 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/** 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/** Run the full review: classify, hint, PII-scan. Pure — no I/O. */\nexport function reviewTools(\n candidates: CandidateTool[],\n safety: SafetyOptions = {},\n): ReviewedTool[] {\n const excluded = (safety.exclude ?? []).map((pattern) => pattern.toLowerCase());\n\n return candidates\n .filter(\n (tool) =>\n !excluded.some(\n (pattern) =>\n tool.name.toLowerCase().includes(pattern) ||\n tool.source.ref.toLowerCase().includes(pattern),\n ),\n )\n .map((tool) => {\n const sideEffect = classifySideEffect(tool);\n return {\n ...tool,\n sideEffect,\n riskTier: riskTierFor(sideEffect),\n hints: hintsFor(tool, sideEffect),\n piiInOutput: findPiiFields(tool.outputSchema, safety.piiFields),\n };\n });\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 — \" +\n \"make sure your server-side authorization checks apply to tool calls too.\",\n });\n }\n }\n\n return findings;\n}\n"],"mappings":"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","names":[]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node