wizards 0.2.0 → 0.2.1

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.
Files changed (58) hide show
  1. package/apps/runtime/dist/app.js +36 -4
  2. package/apps/runtime/dist/auth/local-ticket.js +8 -5
  3. package/apps/runtime/dist/cli/connect.js +531 -0
  4. package/apps/runtime/dist/cli/environment.js +3 -1
  5. package/apps/runtime/dist/cli/main.js +17 -0
  6. package/apps/runtime/dist/cli/mcp.js +175 -0
  7. package/apps/runtime/dist/cli/setup.js +49 -0
  8. package/apps/runtime/dist/mcp/auth.js +26 -0
  9. package/apps/runtime/dist/mcp/flow-app.js +94 -0
  10. package/apps/runtime/dist/mcp/handler.js +15 -5
  11. package/apps/runtime/dist/mcp/seen.js +65 -0
  12. package/apps/runtime/dist/mcp/tools.js +156 -4
  13. package/apps/runtime/dist/routes/runs.js +7 -3
  14. package/apps/runtime/dist/routes/studio.js +20 -0
  15. package/apps/runtime/dist/secrets/signing.js +21 -3
  16. package/apps/runtime/dist/services/runs.js +190 -28
  17. package/apps/runtime/dist/services/wizards.js +1 -1
  18. package/apps/web/dist/assets/{ChangelogDialog-COqPzrKT.js → ChangelogDialog-Co_9fs61.js} +1 -1
  19. package/apps/web/dist/assets/ChatPanel-Br3nYkUc.js +1 -0
  20. package/apps/web/dist/assets/{CreditsDialog-pRbTW7Xh.js → CreditsDialog-D3fZRqXH.js} +1 -1
  21. package/apps/web/dist/assets/EditorPage-BSjQC6FE.js +2 -0
  22. package/apps/web/dist/assets/{FlowPreview-CDmcN1oX.js → FlowPreview-CjUK5046.js} +1 -1
  23. package/apps/web/dist/assets/{HomePage-DUAiS9pg.js → HomePage-8v4HqIbP.js} +1 -1
  24. package/apps/web/dist/assets/{ImportWizard-pzljVvcr.js → ImportWizard-DiV62Uno.js} +1 -1
  25. package/apps/web/dist/assets/{LocalRuntime-DPJMJlWc.js → LocalRuntime-CTyLGC98.js} +1 -1
  26. package/apps/web/dist/assets/NewWizardPage-BBL2yenw.js +2 -0
  27. package/apps/web/dist/assets/{PublicRunner-DoZdJuyW.js → PublicRunner-BwtoEVlE.js} +1 -1
  28. package/apps/web/dist/assets/{RunnerView-XKosfgO-.js → RunnerView-DOizlTad.js} +1 -1
  29. package/apps/web/dist/assets/SettingsPage-CBDv04YS.js +2 -0
  30. package/apps/web/dist/assets/{SetupPage-BFQ0V__v.js → SetupPage-CfXTOaIm.js} +1 -1
  31. package/apps/web/dist/assets/{SharePage-C1yg5bc2.js → SharePage-CgByqrAF.js} +1 -1
  32. package/apps/web/dist/assets/{api-DijQGkp-.js → api-KMi35npD.js} +1 -1
  33. package/apps/web/dist/assets/changelog-03JgixQA.js +1 -0
  34. package/apps/web/dist/assets/index-B9YVjvVY.css +1 -0
  35. package/apps/web/dist/assets/index-BmpnE7IO.js +10 -0
  36. package/apps/web/dist/assets/meta-4R1Lnr25.js +1 -0
  37. package/apps/web/dist/assets/oss-credits-roB62QJu.js +1 -0
  38. package/apps/web/dist/assets/{outputs-37WFkpRS.js → outputs-COE_nvAw.js} +1 -1
  39. package/apps/web/dist/assets/plug-CLjyCoHB.js +1 -0
  40. package/apps/web/dist/assets/{session-BerOchdC.js → session-B6-6VomQ.js} +1 -1
  41. package/apps/web/dist/assets/type-Bj9H3eOK.js +1 -0
  42. package/apps/web/dist/assets/ui-Cs6lpsmj.js +1 -0
  43. package/apps/web/dist/index.html +6 -5
  44. package/apps/web/dist/mcp/flow.html +748 -0
  45. package/package.json +2 -1
  46. package/plugin/.claude-plugin/plugin.json +1 -1
  47. package/plugin/skills/use-wizard/SKILL.md +18 -0
  48. package/apps/web/dist/assets/ChatPanel-aQP8iz10.js +0 -1
  49. package/apps/web/dist/assets/EditorPage-xkq6pvw-.js +0 -2
  50. package/apps/web/dist/assets/NewWizardPage-TwYgdbKl.js +0 -2
  51. package/apps/web/dist/assets/SettingsPage-C0_peJuV.js +0 -2
  52. package/apps/web/dist/assets/changelog-sa4SXBrg.js +0 -1
  53. package/apps/web/dist/assets/index-BAObuqzs.js +0 -10
  54. package/apps/web/dist/assets/index-BOl3401k.css +0 -1
  55. package/apps/web/dist/assets/meta-ijlGGCOr.js +0 -1
  56. package/apps/web/dist/assets/oss-credits-ClwxOPrf.js +0 -1
  57. package/apps/web/dist/assets/type-c_dt5Ne8.js +0 -1
  58. package/apps/web/dist/assets/ui-DKc92qJ1.js +0 -1
@@ -0,0 +1,175 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, openSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { LOCAL_CLIENT_HEADER, LOCAL_MCP_HEADER, mintLocalTicket } from "../auth/local-ticket.js";
6
+ import { runningRuntime, settings } from "./start.js";
7
+ /**
8
+ * `wizards mcp`: this install's MCP server over stdio, for AI clients that start their
9
+ * servers as a command — Claude Desktop, Cursor, Codex and the ChatGPT app, VS Code, Windsurf,
10
+ * Gemini CLI, Goose, OpenClaw, LM Studio. Each message goes on to the running runtime's
11
+ * `/api/mcp`, signed with the data folder's secret, so no client's config holds a key and a new
12
+ * port after a restart changes nothing. When nothing runs, the runtime is started.
13
+ *
14
+ * stdout carries only MCP messages; everything else goes to stderr.
15
+ */
16
+ const START_TIMEOUT_MS = 60_000;
17
+ /** The JSON-RPC messages of a response: a JSON body (one or a batch) or a server-sent stream. */
18
+ export function messagesOf(contentType, body) {
19
+ if (!body.trim()) {
20
+ return [];
21
+ }
22
+ if (contentType.includes("text/event-stream")) {
23
+ const out = [];
24
+ for (const event of body.split(/\r?\n\r?\n/)) {
25
+ const data = event
26
+ .split(/\r?\n/)
27
+ .filter((line) => line.startsWith("data:"))
28
+ .map((line) => line.slice(5).replace(/^ /, ""))
29
+ .join("\n");
30
+ if (data.trim()) {
31
+ out.push(JSON.parse(data));
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ const parsed = JSON.parse(body);
37
+ return Array.isArray(parsed) ? parsed : [parsed];
38
+ }
39
+ function readSecret(paths, dataDir) {
40
+ const given = process.env.APP_SECRET?.trim() || settings(paths).fileEnv.APP_SECRET?.trim();
41
+ if (given) {
42
+ return given;
43
+ }
44
+ try {
45
+ return readFileSync(join(dataDir, "secret"), "utf8").trim();
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Starts the runtime in the background, the way `wizards start --no-open` would. */
52
+ function startInBackground(paths) {
53
+ mkdirSync(paths.logs, { recursive: true });
54
+ const log = openSync(join(paths.logs, "runtime.log"), "a");
55
+ const child = spawn(process.execPath, [process.argv[1], "start", "--no-open"], {
56
+ detached: true,
57
+ stdio: ["ignore", log, log],
58
+ env: process.env,
59
+ });
60
+ child.unref();
61
+ }
62
+ export async function mcp(paths) {
63
+ const { dataDir } = settings(paths);
64
+ let running = null;
65
+ let clientName = "";
66
+ let protocolVersion = "";
67
+ const runtime = () => {
68
+ running ??= (async () => {
69
+ const found = await runningRuntime(dataDir);
70
+ if (found) {
71
+ return found;
72
+ }
73
+ process.stderr.write("engenty wizards is not running; starting it …\n");
74
+ startInBackground(paths);
75
+ const began = Date.now();
76
+ while (Date.now() - began < START_TIMEOUT_MS) {
77
+ await new Promise((wait) => setTimeout(wait, 300));
78
+ const started = await runningRuntime(dataDir);
79
+ if (started) {
80
+ return started;
81
+ }
82
+ }
83
+ throw new Error(`engenty wizards did not start within a minute; see ${join(paths.logs, "runtime.log")}.`);
84
+ })();
85
+ running.catch(() => {
86
+ running = null;
87
+ });
88
+ return running;
89
+ };
90
+ const post = async (message) => {
91
+ const at = await runtime();
92
+ const secret = readSecret(paths, dataDir);
93
+ if (!secret) {
94
+ throw new Error("The data folder has no secret; start engenty wizards once.");
95
+ }
96
+ const response = await fetch(`http://127.0.0.1:${at.port}/api/mcp`, {
97
+ method: "POST",
98
+ headers: {
99
+ "content-type": "application/json",
100
+ accept: "application/json, text/event-stream",
101
+ [LOCAL_MCP_HEADER]: mintLocalTicket(secret, Date.now(), "local-mcp"),
102
+ ...(clientName ? { [LOCAL_CLIENT_HEADER]: clientName } : {}),
103
+ ...(protocolVersion ? { "mcp-protocol-version": protocolVersion } : {}),
104
+ },
105
+ body: JSON.stringify(message),
106
+ });
107
+ const body = await response.text();
108
+ if (!response.ok && response.status !== 202) {
109
+ throw new Error(`engenty wizards answered ${response.status}: ${body.slice(0, 300)}`);
110
+ }
111
+ return messagesOf(response.headers.get("content-type") ?? "", body);
112
+ };
113
+ const write = (message) => {
114
+ process.stdout.write(`${JSON.stringify(message)}\n`);
115
+ };
116
+ const handle = async (message) => {
117
+ if (message.method === "initialize") {
118
+ const info = message.params?.clientInfo;
119
+ clientName = info?.title || info?.name || "";
120
+ }
121
+ let answers;
122
+ try {
123
+ answers = await post(message);
124
+ }
125
+ catch (error) {
126
+ // The runtime went away (stopped, restarted on another port): once more, with a fresh look.
127
+ running = null;
128
+ try {
129
+ answers = await post(message);
130
+ }
131
+ catch {
132
+ if (message.id !== undefined && message.method) {
133
+ write({
134
+ jsonrpc: "2.0",
135
+ id: message.id,
136
+ error: { code: -32603, message: error.message },
137
+ });
138
+ }
139
+ process.stderr.write(`${error.message}\n`);
140
+ return;
141
+ }
142
+ }
143
+ for (const answer of answers) {
144
+ const version = answer.result?.protocolVersion;
145
+ if (message.method === "initialize" && typeof version === "string") {
146
+ protocolVersion = version;
147
+ }
148
+ write(answer);
149
+ }
150
+ };
151
+ const pending = new Set();
152
+ const lines = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY });
153
+ for await (const line of lines) {
154
+ if (!line.trim()) {
155
+ continue;
156
+ }
157
+ let message;
158
+ try {
159
+ message = JSON.parse(line);
160
+ }
161
+ catch {
162
+ write({
163
+ jsonrpc: "2.0",
164
+ id: null,
165
+ error: { code: -32700, message: "Parse error" },
166
+ });
167
+ continue;
168
+ }
169
+ // Requests are answered as they finish: a long get_run never holds up the next call.
170
+ const job = handle(message).finally(() => pending.delete(job));
171
+ pending.add(job);
172
+ }
173
+ await Promise.all(pending);
174
+ return 0;
175
+ }
@@ -4,6 +4,7 @@ import { delimiter, join } from "node:path";
4
4
  import * as p from "@clack/prompts";
5
5
  import { autostartState, setAutostart } from "./autostart.js";
6
6
  import { installClient, installCommand } from "./clients.js";
7
+ import { appStates, connectApps } from "./connect.js";
7
8
  import { runLogged } from "./exec.js";
8
9
  import { installedByScript, isCheckout, packageVersion, readInstallNote, writeInstallNote, } from "./home.js";
9
10
  import { CLIENTS, detectClients, findChrome, findFfmpeg, findOnPath, suggestedClient, vendorApp, } from "./machine.js";
@@ -176,6 +177,51 @@ async function autostartStep(paths, interactive) {
176
177
  p.log.warn(`Starting at login could not be switched on. ${error.message}`);
177
178
  }
178
179
  }
180
+ /**
181
+ * The wizards in the person's AI apps (Claude Desktop, Cursor, Codex …): offered for each app on
182
+ * this machine that does not have them yet. Asked, never assumed; `disconnect` undoes it.
183
+ */
184
+ async function appsStep(paths, interactive) {
185
+ const states = await appStates(paths);
186
+ const connected = states.filter((s) => s.connected);
187
+ const offered = states.filter((s) => s.installed && !s.connected);
188
+ if (connected.length) {
189
+ p.log.success(`In your AI apps: ${connected.map((s) => s.name).join(", ")}`);
190
+ }
191
+ if (!offered.length) {
192
+ return;
193
+ }
194
+ if (!interactive) {
195
+ p.log.info(dim(`To use your wizards in ${offered.map((s) => s.name).join(", ")}: \`${command()} connect\``));
196
+ return;
197
+ }
198
+ const answer = await p.multiselect({
199
+ message: "Use your wizards in these AI apps? (space to pick, enter to go on)",
200
+ options: offered.map((s) => ({
201
+ value: s.id,
202
+ label: s.name,
203
+ hint: s.widgets ? "shows runs as a widget" : "as text",
204
+ })),
205
+ initialValues: offered.map((s) => s.id),
206
+ required: false,
207
+ });
208
+ if (cancelled(answer)) {
209
+ return "cancelled";
210
+ }
211
+ if (!answer.length) {
212
+ p.log.info(dim(`Later: \`${command()} connect\``));
213
+ return;
214
+ }
215
+ for (const { app, outcome } of await connectApps(paths, answer)) {
216
+ if (outcome.ok) {
217
+ p.log.success(`${app.name}: ${dim(tilde(outcome.message))}`);
218
+ }
219
+ else {
220
+ p.log.warn(`${app.name}: ${outcome.message}${outcome.snippet ? `\n${outcome.snippet}` : ""}`);
221
+ }
222
+ }
223
+ p.log.info(dim("Restart those apps to see the wizards in them."));
224
+ }
179
225
  /** The line that puts `~/.local/bin` on the PATH, and the shell file it belongs in. */
180
226
  function pathLine() {
181
227
  const shell = process.env.SHELL ?? "";
@@ -244,6 +290,9 @@ export async function setup(paths, options) {
244
290
  if ((await autostartStep(paths, interactive)) === "cancelled") {
245
291
  return null;
246
292
  }
293
+ if ((await appsStep(paths, interactive)) === "cancelled") {
294
+ return null;
295
+ }
247
296
  if ((await pathStep(interactive)) === "cancelled") {
248
297
  return null;
249
298
  }
@@ -1,4 +1,5 @@
1
1
  import { API_KEY_PREFIX, verifyLocalKey } from "../auth/keys.js";
2
+ import { LOCAL_CLIENT_HEADER, LOCAL_MCP_HEADER, localTicketExpiry } from "../auth/local-ticket.js";
2
3
  import { env } from "../env.js";
3
4
  import { managed, verifyKey, verifyToken } from "../manage.js";
4
5
  import { LOCAL_TENANT } from "../tenants/tenant.js";
@@ -87,8 +88,33 @@ async function tokenAuth(token) {
87
88
  };
88
89
  return authInfoFor(who, token, claims.clientId);
89
90
  }
91
+ /**
92
+ * A request from `wizards mcp` on this machine: signed with the data folder's secret,
93
+ * which only a runtime alone has. It acts as the admin, like a key from the settings.
94
+ */
95
+ function localAuth(request, ticket) {
96
+ if (managed || !localTicketExpiry(env.authSecret, ticket, Date.now(), "local-mcp")) {
97
+ return challenge(401, "invalid_token", "This local signature is invalid or expired.");
98
+ }
99
+ const client = request.headers
100
+ .get(LOCAL_CLIENT_HEADER)
101
+ ?.replace(/[^\p{L}\p{N} ._-]/gu, "")
102
+ .trim()
103
+ .slice(0, 60) || "MCP";
104
+ const who = {
105
+ userId: "local",
106
+ tenantId: LOCAL_TENANT,
107
+ client,
108
+ scopes: [...SCOPES],
109
+ };
110
+ return authInfoFor(who, ticket, "local");
111
+ }
90
112
  /** Who an MCP or API request acts for: an API key (`x-api-key` or a `wz_…` bearer) or an OAuth access token. */
91
113
  export async function authenticate(request) {
114
+ const local = request.headers.get(LOCAL_MCP_HEADER)?.trim();
115
+ if (local) {
116
+ return localAuth(request, local);
117
+ }
92
118
  const header = request.headers.get("x-api-key")?.trim();
93
119
  const token = bearer(request);
94
120
  const key = header || (token?.startsWith(API_KEY_PREFIX) ? token : null);
@@ -0,0 +1,94 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { RESOURCE_MIME_TYPE, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
6
+ import { env } from "../env.js";
7
+ import { runTicket } from "../secrets/signing.js";
8
+ import { draftIssues, ownedWizard, publishedVersion } from "../services/wizards.js";
9
+ /**
10
+ * The flow widget (MCP Apps): a wizard as its diagram, and a run on it — where it stands, what
11
+ * it made, the page it waits for. Hosts that render MCP Apps (Claude Desktop, Cursor, VS Code,
12
+ * Goose, ChatGPT) show it in the chat; the others get the same tools as text.
13
+ */
14
+ /** Built by the web app (`vite.mcp-app.config.ts`) into one HTML file with everything inline. */
15
+ const FLOW_APP_FILE = resolve(dirname(fileURLToPath(import.meta.url)), "../../../web/dist/mcp/flow.html");
16
+ const MISSING = `<!doctype html><meta charset="utf-8"><body style="font:14px system-ui;padding:16px">
17
+ The flow widget is not built: run <code>pnpm --filter @engenty-wizards/web build</code>.</body>`;
18
+ function readWidget() {
19
+ try {
20
+ return readFileSync(FLOW_APP_FILE, "utf8");
21
+ }
22
+ catch {
23
+ return MISSING;
24
+ }
25
+ }
26
+ const FLOW_APP_HTML = readWidget();
27
+ /**
28
+ * Hosts keep a widget by its URI, so the URI names the build: a new widget gets a new address
29
+ * instead of the cached old one.
30
+ */
31
+ export const FLOW_APP_URI = `ui://engenty-wizards/flow-${createHash("sha256").update(FLOW_APP_HTML).digest("hex").slice(0, 10)}.html`;
32
+ export function registerFlowApp(server) {
33
+ const origin = new URL(env.appUrl).origin;
34
+ registerAppResource(server, "Wizard flow", FLOW_APP_URI, {
35
+ description: "A wizard as a diagram, and a run on it.",
36
+ mimeType: RESOURCE_MIME_TYPE,
37
+ }, async () => ({
38
+ contents: [
39
+ {
40
+ uri: FLOW_APP_URI,
41
+ mimeType: RESOURCE_MIME_TYPE,
42
+ text: FLOW_APP_HTML,
43
+ _meta: {
44
+ ui: {
45
+ // The run and its files are served by this runtime; the fonts by Google.
46
+ csp: {
47
+ connectDomains: [origin],
48
+ resourceDomains: [
49
+ origin,
50
+ "https://fonts.googleapis.com",
51
+ "https://fonts.gstatic.com",
52
+ ],
53
+ },
54
+ prefersBorder: true,
55
+ },
56
+ },
57
+ },
58
+ ],
59
+ }));
60
+ }
61
+ /**
62
+ * The widget's data for a wizard: the published version when there is one (that is what runs),
63
+ * else the draft. A run brings the definition it started on.
64
+ */
65
+ export async function flowView(userId, wizardId, options = {}) {
66
+ const w = await ownedWizard(userId, wizardId);
67
+ const published = options.shows === "draft" ? null : await publishedVersion(w);
68
+ const shows = published ? "published" : "draft";
69
+ const definition = options.definition ?? published?.definition ?? w.draft;
70
+ return {
71
+ wizard: {
72
+ id: w.id,
73
+ title: definition.title,
74
+ description: definition.description,
75
+ avatar: definition.avatar,
76
+ shows,
77
+ published: w.publishedVersion !== null,
78
+ issues: shows === "draft" ? (await draftIssues(w)).length : undefined,
79
+ },
80
+ definition,
81
+ run: options.run,
82
+ runtime: options.run ? { base: env.appUrl, ticket: runTicket(options.run.runId) } : undefined,
83
+ };
84
+ }
85
+ /** A tool's `_meta` that opens the widget with the tool's result. */
86
+ export const flowAppMeta = {
87
+ ui: { resourceUri: FLOW_APP_URI },
88
+ "openai/outputTemplate": FLOW_APP_URI,
89
+ };
90
+ /**
91
+ * Where a result carries the widget's data. The definition stays out of `structuredContent`,
92
+ * which hosts hand to the model as well; `_meta` only reaches the widget.
93
+ */
94
+ export const FLOW_VIEW_KEY = "engenty/flow";
@@ -3,11 +3,13 @@ import { withTenant } from "../db/client.js";
3
3
  import { env } from "../env.js";
4
4
  import { tenantStatus } from "../tenants/control.js";
5
5
  import { authenticate, principalOf } from "./auth.js";
6
+ import { registerFlowApp } from "./flow-app.js";
7
+ import { noteRequest } from "./seen.js";
6
8
  import { registerPrompts, registerTools } from "./tools.js";
7
- const INSTRUCTIONS = `engenty wizards: build page-by-page AI wizards that people open on a shared link.
8
- Start with get_authoring_guide (schema + design rules), look at a close starter with get_starter, then create_wizard and refine with edit_wizard until "issues" is empty.
9
- Check it with start_test_run + get_test_run (waitSeconds) — test runs spend the admin's credits.
10
- Publish only when the admin asks. Hand the admin the studioUrl: the studio shows the wizard as a diagram.`;
9
+ const INSTRUCTIONS = `engenty wizards: page-by-page AI wizards — the person answers pages, AI steps write, research and make images or videos, the person reviews, the result is files.
10
+ Use one: list_wizards, then run_wizard straight away — its widget shows the wizard itself (show_wizard only when the person wants to see a wizard before running it), then follow waitingFor (answer_page, review_step, answer_ask, get_run with waitSeconds). Runs spend credits.
11
+ Build one: get_authoring_guide (schema + design rules), a close starter with get_starter, create_wizard, edit_wizard until "issues" is empty, check with start_test_run + get_test_run. Publish only when the admin asks.
12
+ When a run needs the browser, hand over its browserUrl, the wizard's own run page. The studioUrl is for building only: never send someone who uses a wizard to the studio.`;
11
13
  /** A wizard definition is a few KB; this leaves room and refuses anything far beyond it. */
12
14
  const MAX_BODY_BYTES = 300_000;
13
15
  /**
@@ -18,10 +20,11 @@ const MAX_BODY_BYTES = 300_000;
18
20
  export function mcpHandler() {
19
21
  const allowedHosts = [new URL(env.appUrl).hostname, "localhost", "127.0.0.1"];
20
22
  const handler = createMcpHandler((ctx) => {
21
- const server = new McpServer({ name: "engenty-wizards", version: "0.1.0" }, { instructions: INSTRUCTIONS, capabilities: { tools: {}, prompts: {} } });
23
+ const server = new McpServer({ name: "engenty-wizards", version: "0.1.0" }, { instructions: INSTRUCTIONS, capabilities: { tools: {}, prompts: {}, resources: {} } });
22
24
  const who = principalOf(ctx.authInfo);
23
25
  registerTools(server, who);
24
26
  registerPrompts(server);
27
+ registerFlowApp(server);
25
28
  return server;
26
29
  }, { legacy: "stateless", maxRequestBodySize: MAX_BODY_BYTES });
27
30
  return async (request) => {
@@ -37,6 +40,13 @@ export function mcpHandler() {
37
40
  if ((await tenantStatus(who.tenantId)) === "suspended") {
38
41
  return new Response(JSON.stringify({ error: "tenant_suspended" }), { status: 403 });
39
42
  }
43
+ if (request.method === "POST") {
44
+ const body = await request
45
+ .clone()
46
+ .json()
47
+ .catch(() => null);
48
+ noteRequest(who.tenantId, authInfo.clientId, who.client, body);
49
+ }
40
50
  return withTenant(who.tenantId, () => handler.fetch(request, { authInfo }));
41
51
  };
42
52
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What each AI client did with the MCP server since the runtime started: the test on the
3
+ * "Integrate" page waits for a client to show up, call a tool and show the widget. Kept in memory
4
+ * only; a restart starts the test over.
5
+ */
6
+ /** The ids the studio lists the AI apps by, and the names they give themselves (`clientInfo`). */
7
+ const CLIENT_NAMES = [
8
+ // Claude Desktop calls itself claude-ai, as claude.ai does.
9
+ ["claude-desktop", /claude[- ]?ai|claude desktop/i],
10
+ ["claude-ai", /claude[- ]?ai/i],
11
+ ["claude-code", /claude[- ]?code/i],
12
+ ["codex", /codex/i],
13
+ ["chatgpt", /openai|chatgpt/i],
14
+ ["cursor", /cursor/i],
15
+ // Cursor calls itself cursor-vscode.
16
+ ["vscode", /^(?!.*cursor).*(visual studio code|vs ?code)/i],
17
+ ["windsurf", /windsurf|codeium/i],
18
+ ["gemini", /gemini/i],
19
+ ["goose", /goose/i],
20
+ ["openclaw", /openclaw/i],
21
+ ["lm-studio", /lm[- ]?studio/i],
22
+ ];
23
+ /** The apps a client's name stands for; mostly one. */
24
+ export function appIdsOf(client) {
25
+ return CLIENT_NAMES.filter(([, name]) => name.test(client)).map(([id]) => id);
26
+ }
27
+ const seen = new Map();
28
+ /** The name a client gave on `initialize`, by its sign-in: later requests do not repeat it. */
29
+ const names = new Map();
30
+ /** Notes one MCP request: its JSON-RPC messages, from the client signed in as `signIn`. */
31
+ export function noteRequest(tenantId, signIn, client, body) {
32
+ const messages = (Array.isArray(body) ? body : [body]).filter((m) => Boolean(m) && typeof m === "object");
33
+ const info = messages.find((m) => m.method === "initialize")?.params?.clientInfo;
34
+ const given = info?.title || info?.name;
35
+ if (given) {
36
+ names.set(signIn, given);
37
+ }
38
+ const name = names.get(signIn) ?? client;
39
+ const now = Date.now();
40
+ for (const id of appIdsOf(name)) {
41
+ const key = `${tenantId}\n${id}`;
42
+ const last = seen.get(key) ?? { at: now, toolAt: null, widgetAt: null };
43
+ last.at = now;
44
+ for (const m of messages) {
45
+ if (m.method === "tools/call") {
46
+ last.toolAt = now;
47
+ }
48
+ if (m.method === "resources/read" && String(m.params?.uri ?? "").startsWith("ui://")) {
49
+ last.widgetAt = now;
50
+ }
51
+ }
52
+ seen.set(key, last);
53
+ }
54
+ }
55
+ /** What the tenant's clients did, by app id. */
56
+ export function seenApps(tenantId) {
57
+ const out = {};
58
+ for (const [key, value] of seen) {
59
+ const [tenant, id] = key.split("\n");
60
+ if (tenant === tenantId) {
61
+ out[id] = value;
62
+ }
63
+ }
64
+ return out;
65
+ }