wizards 0.2.0 → 0.2.2
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 +4 -1
- package/apps/runtime/dist/app.js +36 -4
- package/apps/runtime/dist/auth/local-ticket.js +8 -5
- package/apps/runtime/dist/cli/connect.js +531 -0
- package/apps/runtime/dist/cli/environment.js +3 -1
- package/apps/runtime/dist/cli/main.js +17 -0
- package/apps/runtime/dist/cli/mcp.js +175 -0
- package/apps/runtime/dist/cli/setup.js +49 -0
- package/apps/runtime/dist/env.js +1 -1
- package/apps/runtime/dist/mcp/auth.js +26 -0
- package/apps/runtime/dist/mcp/flow-app.js +94 -0
- package/apps/runtime/dist/mcp/handler.js +15 -5
- package/apps/runtime/dist/mcp/seen.js +65 -0
- package/apps/runtime/dist/mcp/tools.js +156 -4
- package/apps/runtime/dist/plugins/loader.js +23 -1
- package/apps/runtime/dist/routes/runs.js +7 -3
- package/apps/runtime/dist/routes/studio.js +20 -0
- package/apps/runtime/dist/secrets/signing.js +21 -3
- package/apps/runtime/dist/services/runs.js +190 -28
- package/apps/runtime/dist/services/wizards.js +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-COqPzrKT.js → ChangelogDialog-4HCt88rf.js} +1 -1
- package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +1 -0
- package/apps/web/dist/assets/{CreditsDialog-pRbTW7Xh.js → CreditsDialog-D3fZRqXH.js} +1 -1
- package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-CDmcN1oX.js → FlowPreview-CjUK5046.js} +1 -1
- package/apps/web/dist/assets/{HomePage-DUAiS9pg.js → HomePage-Cn5TCZk7.js} +1 -1
- package/apps/web/dist/assets/{ImportWizard-pzljVvcr.js → ImportWizard-DiV62Uno.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-DPJMJlWc.js → LocalRuntime-DupxjXff.js} +1 -1
- package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +2 -0
- package/apps/web/dist/assets/{PublicRunner-DoZdJuyW.js → PublicRunner-Axs1zwqe.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-XKosfgO-.js → RunnerView-Bhv6RNME.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +2 -0
- package/apps/web/dist/assets/{SetupPage-BFQ0V__v.js → SetupPage-xiqJMR0J.js} +1 -1
- package/apps/web/dist/assets/{SharePage-C1yg5bc2.js → SharePage-C8B9BUSU.js} +1 -1
- package/apps/web/dist/assets/{api-DijQGkp-.js → api-KMi35npD.js} +1 -1
- package/apps/web/dist/assets/changelog-C2LDEzLQ.js +1 -0
- package/apps/web/dist/assets/index-2Ktgo6f1.js +10 -0
- package/apps/web/dist/assets/index-B9YVjvVY.css +1 -0
- package/apps/web/dist/assets/meta-4R1Lnr25.js +1 -0
- package/apps/web/dist/assets/oss-credits-roB62QJu.js +1 -0
- package/apps/web/dist/assets/{outputs-37WFkpRS.js → outputs-Dhqtf-SA.js} +1 -1
- package/apps/web/dist/assets/plug-CLjyCoHB.js +1 -0
- package/apps/web/dist/assets/{session-BerOchdC.js → session-B6-6VomQ.js} +1 -1
- package/apps/web/dist/assets/type-Bj9H3eOK.js +1 -0
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +1 -0
- package/apps/web/dist/index.html +6 -5
- package/apps/web/dist/mcp/flow.html +748 -0
- package/modules/README.md +1 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +24 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/use-wizard/SKILL.md +18 -0
- package/apps/web/dist/assets/ChatPanel-aQP8iz10.js +0 -1
- package/apps/web/dist/assets/EditorPage-xkq6pvw-.js +0 -2
- package/apps/web/dist/assets/NewWizardPage-TwYgdbKl.js +0 -2
- package/apps/web/dist/assets/SettingsPage-C0_peJuV.js +0 -2
- package/apps/web/dist/assets/changelog-sa4SXBrg.js +0 -1
- package/apps/web/dist/assets/index-BAObuqzs.js +0 -10
- package/apps/web/dist/assets/index-BOl3401k.css +0 -1
- package/apps/web/dist/assets/meta-ijlGGCOr.js +0 -1
- package/apps/web/dist/assets/oss-credits-ClwxOPrf.js +0 -1
- package/apps/web/dist/assets/type-c_dt5Ne8.js +0 -1
- package/apps/web/dist/assets/ui-DKc92qJ1.js +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { apps, connectCommand } from "./connect.js";
|
|
1
2
|
import { layout, packageVersion, readInstallNote } from "./home.js";
|
|
2
3
|
import { CLIENTS, nodeIsCurrent } from "./machine.js";
|
|
4
|
+
import { mcp } from "./mcp.js";
|
|
3
5
|
import { command, setup } from "./setup.js";
|
|
4
6
|
import { open, start } from "./start.js";
|
|
5
7
|
import { autostart, status, stop } from "./status.js";
|
|
@@ -14,6 +16,12 @@ const HELP = `engenty wizards ${packageVersion()}
|
|
|
14
16
|
${command()} stop stop it; the data is kept
|
|
15
17
|
${command()} update the newest version
|
|
16
18
|
${command()} autostart start it at login: on, off (macOS: a LaunchAgent; Linux: a systemd service)
|
|
19
|
+
${command()} connect your wizards in your AI apps: every app found, or name them —
|
|
20
|
+
${apps(layout())
|
|
21
|
+
.map((app) => app.id)
|
|
22
|
+
.join(", ")}
|
|
23
|
+
${command()} disconnect take them out again (same names)
|
|
24
|
+
${command()} mcp the MCP server over stdio, as the AI apps start it
|
|
17
25
|
|
|
18
26
|
--yes, -y ask nothing and install nothing optional
|
|
19
27
|
--client <name> install this AI client without asking: ${CLIENTS.map((c) => c.id).join(", ")}
|
|
@@ -26,6 +34,7 @@ function parse(argv) {
|
|
|
26
34
|
const args = {
|
|
27
35
|
command: "",
|
|
28
36
|
value: "",
|
|
37
|
+
apps: [],
|
|
29
38
|
yes: false,
|
|
30
39
|
open: true,
|
|
31
40
|
print: false,
|
|
@@ -61,6 +70,9 @@ function parse(argv) {
|
|
|
61
70
|
else if (args.command === "autostart" && !args.value) {
|
|
62
71
|
args.value = arg;
|
|
63
72
|
}
|
|
73
|
+
else if (args.command === "connect" || args.command === "disconnect") {
|
|
74
|
+
args.apps.push(arg);
|
|
75
|
+
}
|
|
64
76
|
else if (args.command) {
|
|
65
77
|
return `Unexpected argument ${arg}.`;
|
|
66
78
|
}
|
|
@@ -113,6 +125,11 @@ async function main(argv) {
|
|
|
113
125
|
return update(paths);
|
|
114
126
|
case "autostart":
|
|
115
127
|
return autostart(paths, args.value);
|
|
128
|
+
case "connect":
|
|
129
|
+
case "disconnect":
|
|
130
|
+
return connectCommand(paths, args.apps, args.command === "disconnect");
|
|
131
|
+
case "mcp":
|
|
132
|
+
return mcp(paths);
|
|
116
133
|
default:
|
|
117
134
|
console.error(`Unknown command ${args.command}.\n\n${HELP}`);
|
|
118
135
|
return 2;
|
|
@@ -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
|
}
|
package/apps/runtime/dist/env.js
CHANGED
|
@@ -192,7 +192,7 @@ export const env = {
|
|
|
192
192
|
secretAccessKey: str("S3_SECRET_ACCESS_KEY"),
|
|
193
193
|
},
|
|
194
194
|
turnstile: { siteKey: str("TURNSTILE_SITE_KEY"), secret: str("TURNSTILE_SECRET_KEY") },
|
|
195
|
-
/** Plugins (docs/plugins
|
|
195
|
+
/** Plugins (docs/content/dev/plugins). Code in these folders runs with the runtime's own rights. */
|
|
196
196
|
plugins: {
|
|
197
197
|
/** Folders to look in, besides the `modules/` this runtime ships with. */
|
|
198
198
|
dirs: list("PLUGINS_DIR").map((dir) => resolve(dir)),
|
|
@@ -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:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
}
|