From 7cf9c17ec46717d2f787f4f350524a180aa1db69 Mon Sep 17 00:00:00 2001 From: Sourav Kumar Nanda Date: Fri, 18 Sep 2026 15:01:35 +0530 Subject: [PATCH] Run codegen in the browser: the hosted playground The dev dashboard was reachable only by installing and running a command. The site now serves the same product at /playground: paste a spec, upload a file, or point at a URL, and the tools it finds land in the same dashboard, each with its generated source and a form that calls the endpoint. - packages/codegen: a new `dev` subpath holds the pieces both hosts need. dashboardState (a pipeline run, shaped for the UI) and buildToolRequest (a tool call, as a real HTTP request) moved out of dev/server.ts, which now imports them. dashboardHtml takes mode: "playground" for the few lines whose meaning depends on where the dashboard runs. - buildToolRequest concatenates a spec server's base path instead of resolving the path against it, which is what generated callApi(...) does. The run-it test and the shipped tool now hit the same URL: /api/v3 used to be dropped. - site: POST /api/playground runs the real pipeline in a temporary directory and returns dashboard state. The browser reads the spec and makes the test calls, so the site is never a proxy into its own network, and the page says where the limits bite (CORS, sessions). - The landing demo and the playground mount through one helper, so the demo cannot drift from the dashboard it advertises. - Tests: the shared state mapping and request planner in the package, the shadow-root mount and page-side bridge in the site (happy-dom). - Regenerated the landing demo data, which had drifted from the generator. --- .changeset/hosted-playground.md | 9 + README.md | 2 + docs/notes/2026-09-18-hosted-playground.md | 53 ++++ packages/codegen/package.json | 8 +- packages/codegen/src/dev/index.ts | 20 ++ packages/codegen/src/dev/request.test.ts | 125 ++++++++ packages/codegen/src/dev/request.ts | 90 ++++++ packages/codegen/src/dev/server.ts | 139 +-------- packages/codegen/src/dev/state.test.ts | 145 +++++++++ packages/codegen/src/dev/state.ts | 146 +++++++++ packages/codegen/src/dev/ui.ts | 70 ++--- site/app/(home)/page.tsx | 26 +- site/app/(home)/playground/page.tsx | 25 ++ site/app/api/llm/[...path]/route.ts | 15 +- site/app/api/playground/route.ts | 109 +++++++ site/components/dashboard-demo.tsx | 42 +-- site/components/playground.tsx | 288 ++++++++++++++++++ site/components/site-nav.tsx | 1 + site/content/docs/meta.json | 1 + site/content/docs/playground.mdx | 44 +++ site/content/docs/quickstart.mdx | 3 + site/lib/dashboard-mount.test.ts | 117 +++++++ site/lib/dashboard-mount.ts | 145 +++++++++ site/lib/demo-data.ts | 32 +- site/lib/playground.test.ts | 122 ++++++++ site/lib/playground.ts | 207 +++++++++++++ site/lib/rate-limit.ts | 35 +++ site/package.json | 1 + .../demo/immich-excerpt.openapi.yaml | 0 site/scripts/build-demo-data.mjs | 6 +- site/vitest.config.ts | 14 + 31 files changed, 1807 insertions(+), 233 deletions(-) create mode 100644 .changeset/hosted-playground.md create mode 100644 docs/notes/2026-09-18-hosted-playground.md create mode 100644 packages/codegen/src/dev/index.ts create mode 100644 packages/codegen/src/dev/request.test.ts create mode 100644 packages/codegen/src/dev/request.ts create mode 100644 packages/codegen/src/dev/state.test.ts create mode 100644 packages/codegen/src/dev/state.ts create mode 100644 site/app/(home)/playground/page.tsx create mode 100644 site/app/api/playground/route.ts create mode 100644 site/components/playground.tsx create mode 100644 site/content/docs/playground.mdx create mode 100644 site/lib/dashboard-mount.test.ts create mode 100644 site/lib/dashboard-mount.ts create mode 100644 site/lib/playground.test.ts create mode 100644 site/lib/playground.ts create mode 100644 site/lib/rate-limit.ts rename site/{ => public}/demo/immich-excerpt.openapi.yaml (100%) create mode 100644 site/vitest.config.ts diff --git a/.changeset/hosted-playground.md b/.changeset/hosted-playground.md new file mode 100644 index 0000000..8ff9ec2 --- /dev/null +++ b/.changeset/hosted-playground.md @@ -0,0 +1,9 @@ +--- +"@webmcp-stack/codegen": minor +--- + +Add a `dev` subpath with the dashboard's shared pieces, and let the dashboard run on a host other than the dev server. + +- `dashboardState(run, { label, outDir, overrides })` shapes a pipeline run for the dashboard UI. The dev server now uses it instead of its own private copy. +- `buildToolRequest(route, input, baseUrl)` builds the HTTP request a tool makes, shared by the dev server's run-it test and the browser. It now keeps a spec server's base path (`https://api.example.com/v1`), matching what the generated `callApi(...)` does. +- `dashboardHtml(state, { scoped, mode })` takes `mode: "playground"` for hosts where edits stay in the tab and test calls leave from the browser. The default, `"dev"`, is unchanged. diff --git a/README.md b/README.md index 1fae9b9..7f1bef5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ npx @webmcp-stack/codegen dev # local dashboard: browse, edit, toggle, and npx @webmcp-stack/codegen verify # check the tool set against the standard; exits 1 on errors ``` +No install yet? The [playground](https://webmcp.souravinsights.com/playground) runs this same pipeline on a spec you paste into the browser, and shows the tools it finds in the same dashboard. + ## Why not just ask an LLM to write these? You can, and it works. What you get back is different every time, and nothing checks it. Each tool needs the same small decisions made correctly: read or write, registered or hidden, user confirmation or not, trustworthy output or not. Across 40 endpoints that is hundreds of decisions, easy to forget and tedious to apply by hand. The generator makes each one once, from rules, and applies it to every tool on every run, so you review a diff and gate it in CI. It also knows the spec trivia: a rejected `execute` reaches the agent as a bare `UnknownError` with your message discarded, so generated tools return readable errors instead of throwing. diff --git a/docs/notes/2026-09-18-hosted-playground.md b/docs/notes/2026-09-18-hosted-playground.md new file mode 100644 index 0000000..9f6f1af --- /dev/null +++ b/docs/notes/2026-09-18-hosted-playground.md @@ -0,0 +1,53 @@ +# The hosted playground (2026-09-18) + +The dev dashboard (`webmcp-codegen dev`) was reachable only by installing and running a +command. This note records the decision to put the same product on the site as +`/playground`, so a visitor can see what their spec becomes without a checkout. + +## The shape + +Spec in, tools out, in the browser: + +1. The visitor pastes an OpenAPI document, uploads a file, or points at a URL. +2. `POST /api/playground` writes the spec to a temp directory, runs `runGenerate` with + the real `openapi` and `tools` outputs, and returns dashboard state. Dry run, `force: + true`, so audit errors are reported instead of hiding the tools. +3. The page mounts `dashboardHtml(state, { mode: "playground" })` in a shadow root and + answers the dashboard's three requests in the page itself. + +## Decisions and why + +**The browser reads the spec, not our server.** A demo that fetched any URL a visitor +typed would be an open proxy into the network the site runs in. Client-side fetch keeps +the blast radius at CORS, which is a limit we can explain in one sentence. + +**Tool calls leave from the browser.** Same reason, plus a better story: the call uses the +visitor's own session and network, and we never see the traffic. The cost is that an API +without CORS cannot be called from the playground. The page shows the exact request and +says which of the two failures happened. + +**One UI, two hosts.** `dashboardHtml` grew a `mode: "dev" | "playground"` option for the +five lines whose meaning depends on where the dashboard runs (edits saved or not, calls +server-side or browser-side). Everything else is identical, so the playground cannot +drift into a lookalike. + +**One state mapping, one request planner.** `toUiTool` and the request building moved out +of `dev/server.ts` into `dev/state.ts` and `dev/request.ts`, exported together from the new +`@webmcp-stack/codegen/dev` subpath. Both hosts import them. This is also what surfaced a +real bug: the dashboard's run-it resolved `/pets/{id}` against the spec's server with +`new URL(path, base)`, which drops a base path like `/api/v3`. The generated `callApi(...)` +concatenates and keeps it. The planner now concatenates too, so the test call and the +shipped tool hit the same URL. + +**Edits in the playground stay in the tab.** The dashboard's override endpoint is answered +in-page. Each editor's hint says so in playground mode, and the page repeats it below the +frame. No fake persistence, no account, no storage. + +## Not done, on purpose + +- No server-side spec fetching, even behind an allowlist. It adds an SSRF surface for a + convenience the paste box already covers. +- No saved sessions or shareable playground links. Both need storage, which is the thing + the page promises not to have. +- No `/playground` entry in `sitemap.ts`. It is a tool, not a page to rank; the docs page + and the nav link are the way in. Revisit if it earns organic traffic. diff --git a/packages/codegen/package.json b/packages/codegen/package.json index a35db67..be1c0fb 100644 --- a/packages/codegen/package.json +++ b/packages/codegen/package.json @@ -23,6 +23,10 @@ "types": "./dist/outputs/index.d.ts", "import": "./dist/outputs/index.js" }, + "./dev": { + "types": "./dist/dev/index.d.ts", + "import": "./dist/dev/index.js" + }, "./dev-ui": { "types": "./dist/dev/ui.d.ts", "import": "./dist/dev/ui.js" @@ -33,8 +37,8 @@ "assets" ], "scripts": { - "build": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --clean", - "dev": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --watch", + "build": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --clean", + "dev": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --watch", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/packages/codegen/src/dev/index.ts b/packages/codegen/src/dev/index.ts new file mode 100644 index 0000000..9f88d33 --- /dev/null +++ b/packages/codegen/src/dev/index.ts @@ -0,0 +1,20 @@ +/** + * The dashboard's building blocks, for hosts other than the dev server. + * + * The dev server (`webmcp-codegen dev`) and the site's hosted playground both + * mount the same UI from the same state, so they read from here: + * + * dashboardHtml the page itself, as a string + * dashboardState a pipeline run, shaped for the UI + * buildToolRequest a tool call, as a real HTTP request + * + * All three are safe in a browser bundle: no filesystem, no server-only + * imports. Import from "@webmcp-stack/codegen/dev". + */ + +export type { ToolRequest, ToolRequestResult, ToolRoute } from "./request.js"; +export { buildToolRequest } from "./request.js"; +export type { DashboardStateOptions, UiState, UiTool } from "./state.js"; +export { dashboardState } from "./state.js"; +export type { DashboardMode } from "./ui.js"; +export { dashboardHtml } from "./ui.js"; diff --git a/packages/codegen/src/dev/request.test.ts b/packages/codegen/src/dev/request.test.ts new file mode 100644 index 0000000..16fefc4 --- /dev/null +++ b/packages/codegen/src/dev/request.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { buildToolRequest } from "./request.js"; + +describe("buildToolRequest", () => { + it("fills path params and sets query params", () => { + const built = buildToolRequest( + { + verb: "GET", + pathTemplate: "/pets/{petId}/photos", + paramLocations: { path: ["petId"], query: ["limit"], body: [] }, + serverUrl: "https://api.example.com/v1", + }, + { petId: "7", limit: 5 }, + ); + + expect(built).toEqual({ + request: { + method: "GET", + url: "https://api.example.com/v1/pets/7/photos?limit=5", + }, + }); + }); + + it("keeps the base path a spec's server URL carries", () => { + // The Petstore shape: servers[0] ends in /api/v3 and every path is + // relative to it. Resolving "/pet/7" against the base would drop the + // prefix and 404, so the base is concatenated, exactly as the generated + // callApi(...) does. + const built = buildToolRequest( + { + verb: "GET", + pathTemplate: "/pet/{petId}", + paramLocations: { path: ["petId"], query: [], body: [] }, + serverUrl: "https://petstore3.swagger.io/api/v3", + }, + { petId: "7" }, + ); + + expect("request" in built && built.request.url).toBe( + "https://petstore3.swagger.io/api/v3/pet/7", + ); + }); + + it("prefers the typed base URL over the spec's server", () => { + const built = buildToolRequest( + { + verb: "GET", + pathTemplate: "/albums", + paramLocations: { path: [], query: [], body: [] }, + serverUrl: "https://production.example.com/api", + }, + {}, + "http://localhost:3000/api/", + ); + + expect("request" in built && built.request.url).toBe("http://localhost:3000/api/albums"); + }); + + it("sends body fields as JSON, and a whole body field as itself", () => { + const fields = buildToolRequest( + { + verb: "POST", + pathTemplate: "/albums", + paramLocations: { path: [], query: [], body: ["albumName", "description"] }, + serverUrl: "https://api.example.com", + }, + { albumName: "Trips", description: "2026" }, + ); + expect("request" in fields && fields.request.body).toEqual({ + albumName: "Trips", + description: "2026", + }); + + const whole = buildToolRequest( + { + verb: "POST", + pathTemplate: "/search", + paramLocations: { path: [], query: [], body: ["body"] }, + serverUrl: "https://api.example.com", + }, + { body: { q: "trips" } }, + ); + expect("request" in whole && whole.request.body).toEqual({ q: "trips" }); + }); + + it("leaves an empty input out of the URL rather than sending nulls", () => { + const built = buildToolRequest( + { + verb: "GET", + pathTemplate: "/albums", + paramLocations: { path: [], query: ["shared", "limit"], body: [] }, + serverUrl: "https://api.example.com", + }, + { shared: undefined, limit: null }, + ); + + expect("request" in built && built.request.url).toBe("https://api.example.com/albums"); + expect("request" in built && built.request.body).toBeUndefined(); + }); + + it("says what to do when the spec lists no absolute server", () => { + const built = buildToolRequest( + { verb: "GET", pathTemplate: "/albums", paramLocations: { path: [], query: [], body: [] } }, + {}, + ); + + expect(built).toEqual({ + error: + "No base URL: the spec lists no absolute server. Type your app's URL " + + '(e.g. http://localhost:3000) in the "base URL" field and run again.', + }); + }); + + it("refuses a tool with no route, and a base URL that is not a URL", () => { + const noRoute = buildToolRequest({ serverUrl: "https://api.example.com" }, {}); + expect(noRoute).toEqual({ error: "This tool has no route to call." }); + + const badBase = buildToolRequest( + { verb: "GET", pathTemplate: "/albums", paramLocations: { path: [], query: [], body: [] } }, + {}, + "not a url", + ); + expect("error" in badBase && badBase.error).toContain("is not a URL"); + }); +}); diff --git a/packages/codegen/src/dev/request.ts b/packages/codegen/src/dev/request.ts new file mode 100644 index 0000000..321e130 --- /dev/null +++ b/packages/codegen/src/dev/request.ts @@ -0,0 +1,90 @@ +/** + * Turning a tool plus typed input into the HTTP request that tool makes. + * + * The dev server uses this to run a tool server-side; the hosted playground + * uses the same function in the visitor's browser. One function, so a tool + * tested in the playground makes the request the CLI's generated code makes. + * + * Nothing here touches the filesystem or the network, so a browser bundle + * can import it. + */ + +/** The route facts a request needs. Every tool the dashboard shows carries them. */ +export interface ToolRoute { + /** "GET", "POST", ... as the dashboard shows it. */ + verb?: string; + pathTemplate?: string; + paramLocations?: { path: string[]; query: string[]; body: string[] }; + serverUrl?: string; +} + +export interface ToolRequest { + method: string; + /** Absolute URL, path params filled in and query params set. */ + url: string; + /** Present only when the tool has body fields. */ + body?: unknown; +} + +export type ToolRequestResult = { request: ToolRequest } | { error: string }; + +/** + * Build the request for one tool call. `baseUrl` overrides the spec's server, + * which is what the dashboard's base URL field is for: the spec often lists a + * production host, while the developer wants to test against localhost. + */ +export function buildToolRequest( + route: ToolRoute, + input: Record, + baseUrl?: string, +): ToolRequestResult { + const base = (baseUrl || route.serverUrl || "").replace(/\/+$/, ""); + if (!base) { + return { + error: + "No base URL: the spec lists no absolute server. Type your app's URL " + + '(e.g. http://localhost:3000) in the "base URL" field and run again.', + }; + } + if (!route.pathTemplate || !route.verb) { + return { error: "This tool has no route to call." }; + } + + let path = route.pathTemplate; + for (const param of route.paramLocations?.path ?? []) { + path = path.replace(`{${param}}`, encodeURIComponent(String(input[param] ?? ""))); + } + if (!path.startsWith("/")) path = `/${path}`; + + // Concatenated, not resolved against the base: a spec server carries its + // base path ("https://api.example.com/v1"), and resolving "/pets" against + // it would drop the "/v1". The generated code concatenates for the same + // reason, so the test call and the shipped tool hit the same URL. + let url: URL; + try { + url = new URL(`${base}${path}`); + } catch { + return { error: `"${base}" is not a URL. Fix the base URL field and run again.` }; + } + + for (const param of route.paramLocations?.query ?? []) { + const value = input[param]; + if (value !== undefined && value !== null) url.searchParams.set(param, String(value)); + } + + const bodyFields = route.paramLocations?.body ?? []; + const body = + bodyFields.length === 1 && bodyFields[0] === "body" + ? input.body + : bodyFields.length > 0 + ? Object.fromEntries(bodyFields.map((field) => [field, input[field]])) + : undefined; + + return { + request: { + method: route.verb, + url: url.toString(), + ...(body !== undefined ? { body } : {}), + }, + }; +} diff --git a/packages/codegen/src/dev/server.ts b/packages/codegen/src/dev/server.ts index 07c9540..f3ef4b5 100644 --- a/packages/codegen/src/dev/server.ts +++ b/packages/codegen/src/dev/server.ts @@ -14,11 +14,11 @@ import { spawn } from "node:child_process"; import { createServer, type Server } from "node:http"; -import { basename } from "node:path"; import { loadDataFile, saveDataFile } from "../data-file.js"; import { runGenerate } from "../pipeline.js"; import { resolveSetup } from "../setup.js"; -import type { GeneratedFile, JsonSchema, ReviewedTool, ToolOverrides } from "../types.js"; +import { buildToolRequest } from "./request.js"; +import { dashboardState, type UiState, type UiTool } from "./state.js"; import { dashboardHtml } from "./ui.js"; export interface DevServerOptions { @@ -35,41 +35,6 @@ interface RunRequest { baseUrl?: string; } -/** The JSON shape the UI renders. */ -interface DashboardState { - label: string; - outDir?: string; - tools: { - name: string; - verb?: string; - path?: string; - /** Where this tool came from: the route, the schema, or both ("merged"). */ - provenance: string; - /** Present when the tool annotates a form instead of generating a file. */ - form?: { path: string }; - description: string; - sideEffect: string; - riskTier: string; - enabled: boolean; - withheld: boolean; - endpointRole: string; - piiInOutput: string[]; - inputSchema: JsonSchema; - /** Per-field text the developer overrode, so the editor shows their words. */ - fieldOverrides?: Record; - /** Route info the direct "run it" test needs to build a real request. */ - pathTemplate?: string; - paramLocations?: { path: string[]; query: string[]; body: string[] }; - serverUrl?: string; - requiresAuth?: boolean; - /** The generated file, shown on demand in the detail pane. */ - source?: { fileName: string; code: string }; - findings: { level: string; message: string }[]; - }[]; - skipped: { ref: string; reason: string }[]; - notes: string[]; -} - export async function startDevServer(options: DevServerOptions): Promise { const setup = await resolveSetup(options.cwd, { dryRun: true, @@ -80,7 +45,7 @@ export async function startDevServer(options: DevServerOptions): Promise /** Re-run the pipeline fresh on every state request: edits to the spec * show up on reload without restarting the dashboard. */ - async function currentState(): Promise { + async function currentState(): Promise { const data = await loadDataFile(options.cwd); const result = await runGenerate(setup.config, { cwd: options.cwd, @@ -90,15 +55,11 @@ export async function startDevServer(options: DevServerOptions): Promise // edits follow the renamed tool instead of vanishing from the UI. previousNames: data.names, }); - return { + return dashboardState(result, { label: setup.label, outDir: setup.config.outputs[0]?.outDir, - tools: result.tools.map((tool) => - toUiTool(tool, result.findings, result.files, data.overrides), - ), - skipped: result.skipped, - notes: result.notes, - }; + overrides: data.overrides, + }); } const server = createServer(async (request, response) => { @@ -180,100 +141,28 @@ export async function startDevServer(options: DevServerOptions): Promise return server; } -function toUiTool( - tool: ReviewedTool, - findings: { level: string; tool?: string; message: string }[], - files: GeneratedFile[], - overrides: ToolOverrides | undefined, -): DashboardState["tools"][number] { - // Route info only exists for endpoint-backed tools. A standalone schema - // tool has no verb, and showing one would be a lie; the provenance line - // carries the truth instead ("schema: create-trip"). - const route = tool.endpointRef ?? (tool.source.kind === "openapi" ? tool.source.ref : ""); - const [verb, ...rest] = route ? route.split(" ") : [undefined, ""]; - // Provenance names where the tool came from, exactly once. The route line - // owns the verb and path, so a merged tool names only its schema, and a - // pure OpenAPI tool has nothing left to say: no line at all. - const provenance = tool.endpointRef - ? `merged: the ${tool.source.ref} schema and this route` - : tool.source.kind === "schema" - ? `schema: ${tool.source.ref}` - : ""; - // The dry run already holds every file's contents in memory, so the - // dashboard can show the real generated source per tool - the same - // progressive disclosure the site's demo has, against live output. - const file = files.find((f) => basename(f.path) === `${tool.name}.webmcp.ts`); - return { - name: tool.name, - verb, - path: rest.join(" "), - provenance, - ...(tool.form ? { form: tool.form } : {}), - description: tool.description, - sideEffect: tool.sideEffect, - riskTier: tool.riskTier, - enabled: tool.enabledByDefault, - withheld: tool.withheld, - endpointRole: tool.endpointRole, - piiInOutput: tool.piiInOutput, - inputSchema: tool.inputSchema, - ...(overrides?.[tool.name]?.fields ? { fieldOverrides: overrides[tool.name]?.fields } : {}), - ...(tool.pathTemplate ? { pathTemplate: tool.pathTemplate } : {}), - ...(tool.paramLocations ? { paramLocations: tool.paramLocations } : {}), - ...(tool.serverUrl ? { serverUrl: tool.serverUrl } : {}), - requiresAuth: tool.requiresAuth, - ...(file ? { source: { fileName: basename(file.path), code: file.contents } } : {}), - findings: findings - .filter((finding) => finding.tool === tool.name) - .map((finding) => ({ level: finding.level, message: finding.message })), - }; -} - /** * The direct "run it" test: call the endpoint the way the generated * execute() would, but server-side. Two honest limitations the UI states: * there is no browser session here (auth cookies do not apply), and the * call needs an absolute base URL - the spec's servers entry or one the * developer types in. + * + * The request itself is built by buildToolRequest, the same function the + * site's playground runs in the visitor's browser. */ async function runEndpoint( - tool: DashboardState["tools"][number], + tool: UiTool, input: Record, baseUrlOverride?: string, ): Promise<{ ok: boolean; status?: number; body?: unknown; error?: string }> { - const base = baseUrlOverride ?? tool.serverUrl; - if (!base) { - return { - ok: false, - error: - "No base URL: the spec lists no absolute server. Type your app's URL " + - '(e.g. http://localhost:3000) in the "base URL" field and run again.', - }; - } - if (!tool.pathTemplate || !tool.verb) { - return { ok: false, error: "This tool has no route to call." }; - } - - let path = tool.pathTemplate; - for (const param of tool.paramLocations?.path ?? []) { - path = path.replace(`{${param}}`, encodeURIComponent(String(input[param] ?? ""))); - } - const url = new URL(path, base); - for (const param of tool.paramLocations?.query ?? []) { - const value = input[param]; - if (value !== undefined && value !== null) url.searchParams.set(param, String(value)); - } - const bodyFields = tool.paramLocations?.body ?? []; - const body = - bodyFields.length === 1 && bodyFields[0] === "body" - ? input.body - : bodyFields.length > 0 - ? Object.fromEntries(bodyFields.map((field) => [field, input[field]])) - : undefined; + const built = buildToolRequest(tool, input, baseUrlOverride); + if ("error" in built) return { ok: false, error: built.error }; + const { method, url, body } = built.request; try { const response = await fetch(url, { - method: tool.verb, + method, headers: body !== undefined ? { "content-type": "application/json" } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }); diff --git a/packages/codegen/src/dev/state.test.ts b/packages/codegen/src/dev/state.test.ts new file mode 100644 index 0000000..1c16319 --- /dev/null +++ b/packages/codegen/src/dev/state.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { AuditFinding, GeneratedFile, ReviewedTool } from "../types.js"; +import { dashboardState } from "./state.js"; + +/** A reviewed tool, as the safety layer would have produced it. */ +function reviewedTool(overrides: Partial = {}): ReviewedTool { + return { + id: "GET /albums", + name: "get-all-albums", + source: { kind: "openapi", ref: "GET /albums" }, + inputSchema: { + type: "object", + properties: { shared: { type: "boolean", description: "Only shared albums" } }, + }, + inputTypeName: "GetAllAlbumsInput", + httpMethod: "GET", + pathTemplate: "/albums", + paramLocations: { path: [], query: ["shared"], body: [] }, + serverUrl: "https://photos.example.com/api", + sideEffect: "read", + endpointRole: "endpoint", + enabledByDefault: true, + withheld: false, + requiresAuth: false, + description: "List all albums", + descriptionSource: "openapi-summary", + riskTier: "safe-read", + hints: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + untrustedContentHint: false, + }, + piiInOutput: [], + ...overrides, + }; +} + +/** The generated file for a tool, named the way the tools output names it. */ +function generatedFile(name: string): GeneratedFile { + return { + path: `/scratch/src/webmcp/${name}.webmcp.ts`, + contents: `// ${name}: generated`, + action: "create", + }; +} + +describe("dashboardState", () => { + it("carries the route facts the run-it test needs", () => { + const state = dashboardState( + { + tools: [reviewedTool()], + files: [generatedFile("get-all-albums")], + skipped: [], + notes: [], + findings: [], + }, + { label: "immich.openapi.yaml", outDir: "src/webmcp" }, + ); + + expect(state.label).toBe("immich.openapi.yaml"); + expect(state.outDir).toBe("src/webmcp"); + expect(state.tools).toHaveLength(1); + expect(state.tools[0]!).toMatchObject({ + name: "get-all-albums", + verb: "GET", + path: "/albums", + pathTemplate: "/albums", + paramLocations: { path: [], query: ["shared"], body: [] }, + serverUrl: "https://photos.example.com/api", + sideEffect: "read", + enabled: true, + withheld: false, + }); + }); + + it("attaches each tool's own generated source, and only its own findings", () => { + const state = dashboardState( + { + tools: [reviewedTool(), reviewedTool({ id: "POST /albums", name: "create-album" })], + files: [generatedFile("get-all-albums"), generatedFile("create-album")], + skipped: [{ ref: "POST /webhooks/immich", reason: "webhooks are never tools" }], + notes: ["stripped the shared v1 prefix"], + findings: [ + { level: "warning", tool: "create-album", message: "No output schema." }, + { level: "error", message: "Two tools collided." }, + ] satisfies AuditFinding[], + }, + { label: "immich.openapi.yaml" }, + ); + + const [albums, create] = state.tools; + + expect(albums!.source).toEqual({ + fileName: "get-all-albums.webmcp.ts", + code: "// get-all-albums: generated", + }); + expect(create!.source?.fileName).toBe("create-album.webmcp.ts"); + // A finding about one tool never shows up on another, and a project-level + // finding (no tool) belongs to no tool's pane. + expect(albums!.findings).toEqual([]); + expect(create!.findings).toEqual([{ level: "warning", message: "No output schema." }]); + expect(state.skipped).toHaveLength(1); + expect(state.notes).toEqual(["stripped the shared v1 prefix"]); + }); + + it("shows a developer's saved field text instead of the synthesized line", () => { + const state = dashboardState( + { + tools: [reviewedTool()], + files: [], + skipped: [], + notes: [], + findings: [], + }, + { + label: "immich.openapi.yaml", + overrides: { "get-all-albums": { fields: { shared: "Only albums shared with me" } } }, + }, + ); + + expect(state.tools[0]!.fieldOverrides).toEqual({ shared: "Only albums shared with me" }); + }); + + it("marks provenance only where there is something to say", () => { + const merged = reviewedTool({ + source: { kind: "schema", ref: "create-trip" }, + endpointRef: "POST /v1/trips", + }); + const schemaOnly = reviewedTool({ source: { kind: "schema", ref: "list-trips" } }); + + const state = dashboardState( + { tools: [merged, schemaOnly], files: [], skipped: [], notes: [], findings: [] }, + { label: "app.schemas.ts" }, + ); + + const [mergedTool, schemaTool] = state.tools; + + expect(mergedTool!.provenance).toBe("merged: the create-trip schema and this route"); + expect(mergedTool!.verb).toBe("POST"); + expect(mergedTool!.path).toBe("/v1/trips"); + expect(schemaTool!.provenance).toBe("schema: list-trips"); + expect(schemaTool!.verb).toBeUndefined(); + }); +}); diff --git a/packages/codegen/src/dev/state.ts b/packages/codegen/src/dev/state.ts new file mode 100644 index 0000000..31431b1 --- /dev/null +++ b/packages/codegen/src/dev/state.ts @@ -0,0 +1,146 @@ +/** + * The dashboard's data shape, and the one function that builds it. + * + * Two dashboards render this state: the local dev server + * (`webmcp-codegen dev`) and the hosted playground on the site. Both read + * from this module, so the hosted playground is the same product as the + * local one instead of a copy that drifts. + * + * Nothing here touches the filesystem, so a browser bundle can import it. + */ + +import type { + AuditFinding, + GeneratedFile, + JsonSchema, + ReviewedTool, + SkippedEndpoint, + ToolOverrides, +} from "../types.js"; + +export interface UiTool { + name: string; + description: string; + sideEffect: string; + enabled: boolean; + endpointRole: string; + piiInOutput: string[]; + findings: { level: string; message: string }[]; + inputSchema?: JsonSchema; + serverUrl?: string; + requiresAuth?: boolean; + verb?: string; + path?: string; + /** Where this tool came from: the route, the schema, or both ("merged"). */ + provenance?: string; + /** Present when the tool annotates a form instead of generating a file. */ + form?: { path: string }; + /** Per-field text the developer overrode, so the editor shows their words. */ + fieldOverrides?: Record; + /** The generated file, shown on demand. The dashboard is the disclosure. */ + source?: { fileName: string; code: string }; + /** How dangerous the tool is to expose to agents. */ + riskTier?: string; + /** A withheld tool is not registered at all; agents never see it. */ + withheld?: boolean; + /** Route facts the "run it" test needs to build a real request. */ + pathTemplate?: string; + paramLocations?: { path: string[]; query: string[]; body: string[] }; +} + +export interface UiState { + label: string; + outDir?: string; + tools: UiTool[]; + skipped: { ref: string; reason: string }[]; + notes: string[]; +} + +export interface DashboardStateOptions { + /** What the spec is called, shown in the sidebar. */ + label: string; + /** Where the tools would land, shown in the sidebar. */ + outDir?: string; + /** Hand-authored tweaks, so the editor shows the developer's own words. */ + overrides?: ToolOverrides; +} + +/** + * The dashboard state for one pipeline run. Takes the pieces of a + * GenerateResult the UI actually renders: a run's files and findings are + * what put source code and audit warnings on screen. + */ +export function dashboardState( + run: { + tools: ReviewedTool[]; + files: GeneratedFile[]; + skipped: SkippedEndpoint[]; + notes: string[]; + findings: AuditFinding[]; + }, + options: DashboardStateOptions, +): UiState { + return { + label: options.label, + outDir: options.outDir, + tools: run.tools.map((tool) => toUiTool(tool, run.findings, run.files, options.overrides)), + skipped: run.skipped, + notes: run.notes, + }; +} + +function toUiTool( + tool: ReviewedTool, + findings: AuditFinding[], + files: GeneratedFile[], + overrides: ToolOverrides | undefined, +): UiTool { + // Route info only exists for endpoint-backed tools. A standalone schema + // tool has no verb, and showing one would be a lie; the provenance line + // carries the truth instead ("schema: create-trip"). + const route = tool.endpointRef ?? (tool.source.kind === "openapi" ? tool.source.ref : ""); + const [verb, ...rest] = route ? route.split(" ") : [undefined, ""]; + // Provenance names where the tool came from, exactly once. The route line + // owns the verb and path, so a merged tool names only its schema, and a + // pure OpenAPI tool has nothing left to say: no line at all. + const provenance = tool.endpointRef + ? `merged: the ${tool.source.ref} schema and this route` + : tool.source.kind === "schema" + ? `schema: ${tool.source.ref}` + : ""; + // The dry run already holds every file's contents in memory, so the + // dashboard can show the real generated source per tool - the same + // progressive disclosure the site's demo has, against live output. + const fileName = `${tool.name}.webmcp.ts`; + const file = files.find((candidate) => baseName(candidate.path) === fileName); + return { + name: tool.name, + verb, + path: rest.join(" "), + provenance, + ...(tool.form ? { form: tool.form } : {}), + description: tool.description, + sideEffect: tool.sideEffect, + riskTier: tool.riskTier, + enabled: tool.enabledByDefault, + withheld: tool.withheld, + endpointRole: tool.endpointRole, + piiInOutput: tool.piiInOutput, + inputSchema: tool.inputSchema, + ...(overrides?.[tool.name]?.fields ? { fieldOverrides: overrides[tool.name]?.fields } : {}), + ...(tool.pathTemplate ? { pathTemplate: tool.pathTemplate } : {}), + ...(tool.paramLocations ? { paramLocations: tool.paramLocations } : {}), + ...(tool.serverUrl ? { serverUrl: tool.serverUrl } : {}), + requiresAuth: tool.requiresAuth, + ...(file ? { source: { fileName, code: file.contents } } : {}), + findings: findings + .filter((finding) => finding.tool === tool.name) + .map((finding) => ({ level: finding.level, message: finding.message })), + }; +} + +/** The last path segment. Spelled out so this module stays browser-safe. */ +function baseName(path: string): string { + const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return cut === -1 ? path : path.slice(cut + 1); +} diff --git a/packages/codegen/src/dev/ui.ts b/packages/codegen/src/dev/ui.ts index 2db2f4e..4ddad16 100644 --- a/packages/codegen/src/dev/ui.ts +++ b/packages/codegen/src/dev/ui.ts @@ -9,36 +9,18 @@ * No framework, no build step. Plain HTML/CSS/JS shipped as a string. */ -interface UiTool { - name: string; - description: string; - sideEffect: string; - enabled: boolean; - endpointRole: string; - piiInOutput: string[]; - findings: { level: string; message: string }[]; - inputSchema?: Record; - serverUrl?: string; - requiresAuth?: boolean; - verb?: string; - path?: string; - /** Where this tool came from: the route, the schema, or both ("merged"). */ - provenance?: string; - /** Present when the tool annotates a form instead of generating a file. */ - form?: { path: string }; - /** Per-field text the developer overrode, so the editor shows their words. */ - fieldOverrides?: Record; - /** The generated file, shown on demand. The dashboard is the disclosure. */ - source?: { fileName: string; code: string }; -} +import type { UiState } from "./state.js"; -interface UiState { - label: string; - outDir?: string; - tools: UiTool[]; - skipped: { ref: string; reason: string }[]; - notes: string[]; -} +export type { UiState, UiTool } from "./state.js"; + +/** + * Where the dashboard is mounted, which decides what a few lines of copy + * say. Both hosts render this one page, so the words have to match the + * host: in the dev server an edit is saved to .webmcp-codegen.json and a + * test call runs server-side; in the hosted playground an edit lives in the + * tab and a test call goes out from the browser. + */ +export type DashboardMode = "dev" | "playground"; /** * The dashboard page. With no argument it boots by fetching /api/state @@ -46,7 +28,10 @@ interface UiState { * instead, with no network: the site's landing demo mounts this exact UI * statically, so the demo can never drift from the product. */ -export function dashboardHtml(embeddedState?: UiState, opts?: { scoped?: boolean }): string { +export function dashboardHtml( + embeddedState?: UiState, + opts?: { scoped?: boolean; mode?: DashboardMode }, +): string { return ` @@ -835,6 +820,7 @@ export function dashboardHtml(embeddedState?: UiState, opts?: { scoped?: boolean