Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/hosted-playground.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions docs/notes/2026-09-18-hosted-playground.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions packages/codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
},
Expand Down
20 changes: 20 additions & 0 deletions packages/codegen/src/dev/index.ts
Original file line number Diff line number Diff line change
@@ -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";
125 changes: 125 additions & 0 deletions packages/codegen/src/dev/request.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
90 changes: 90 additions & 0 deletions packages/codegen/src/dev/request.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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 } : {}),
},
};
}
Loading
Loading