@chrischall/mcp-host-cli 0.0.0-stage → 2.33.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.
package/src/wizard.mjs ADDED
@@ -0,0 +1,416 @@
1
+ // The interactive register wizard: `mcp-host wizard`, or `mcp-host register`
2
+ // with no flags on a terminal.
3
+ //
4
+ // A conversation over the SAME control API the flags use — `api` is passed
5
+ // in (the (method, path, body) function index.mjs owns) so a test can
6
+ // record every call, and the prompter is passed in so a test can script the
7
+ // answers. Nothing here resolves a source or validates a slug beyond what it
8
+ // needs to ask the next question: the preview endpoint is the resolver and
9
+ // the create is the validator, and a copy of either rule would drift.
10
+ //
11
+ // Every line it prints goes to `stderr`, like the prompts: stdout stays the
12
+ // JSON of the registration that was created, exactly as `register` prints.
13
+
14
+ import { DEP_GRAMMAR, bundleSummaryLines, parseGithubSpec, parseMcpbSpec, parseNpmSpec, suggestSlug } from './lib.mjs';
15
+ import { storeSecretSealed } from './secrets.mjs';
16
+
17
+ /** Secret values never pass through this string; env values and names do. */
18
+ function shellQuote(value) {
19
+ return /^[A-Za-z0-9_@%+=:,.#/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
20
+ }
21
+
22
+ /**
23
+ * A source back to the `--npm`/`--github`/`--mcpb` spelling `register`
24
+ * accepts. An mcpb source in the BODY is already by hash or by URL — a path
25
+ * was uploaded before the body existed — so the flag it re-spells names the
26
+ * hash, which is the form that works from any machine.
27
+ */
28
+ function sourceSpec(source) {
29
+ if (source.type === 'npm') return `${source.pkg}${source.version ? `@${source.version}` : ''}`;
30
+ if (source.type === 'mcpb') return source.url ?? `sha256:${source.sha256}`;
31
+ return `${source.repo}${source.ref ? `#${source.ref}` : source.commit ? `#${source.commit}` : ''}`;
32
+ }
33
+
34
+ /** A dependency input back to the one-line `--dep` grammar. */
35
+ function depSpec(dep) {
36
+ if (dep.kind === 'npm') return `npm:${dep.pkg}${dep.version ? `@${dep.version}` : ''}`;
37
+ return `github:${dep.repo}@${dep.tag}:${dep.asset}#${dep.bin.join(',')}`;
38
+ }
39
+
40
+ /**
41
+ * The flags that would have produced this body, for the epilogue. Teaching
42
+ * the flags is the point: the wizard is for the first registration, and the
43
+ * second one is usually a script. Secret VALUES cannot appear — the body
44
+ * carries references by name, and the value was sent on its own PUT.
45
+ */
46
+ export function equivalentCommand(body) {
47
+ const parts = ['mcp-host register', '--slug', shellQuote(body.slug)];
48
+ if (body.account !== undefined) parts.push('--account', shellQuote(body.account));
49
+ if (body.source.type === 'npm') parts.push('--npm', shellQuote(sourceSpec(body.source)));
50
+ else if (body.source.type === 'mcpb') parts.push('--mcpb', shellQuote(sourceSpec(body.source)));
51
+ else {
52
+ parts.push('--github', shellQuote(sourceSpec(body.source)));
53
+ if (body.source.subdir) parts.push('--subdir', shellQuote(body.source.subdir));
54
+ if (body.source.build) parts.push('--build', shellQuote(body.source.build));
55
+ }
56
+ if (body.displayName) parts.push('--name', shellQuote(body.displayName));
57
+ if (body.command?.bin) parts.push('--bin', shellQuote(body.command.bin));
58
+ for (const [name, value] of Object.entries(body.env ?? {})) {
59
+ if (typeof value === 'string') parts.push('--env', shellQuote(`${name}=${value}`));
60
+ else parts.push('--secret-env', shellQuote(`${name}=${value.secret}`));
61
+ }
62
+ for (const dep of body.dependencies ?? []) {
63
+ parts.push('--dep', shellQuote(depSpec(dep)));
64
+ if (dep.sha256) parts.push('--dep-sha256', shellQuote(`${dep.asset}=${dep.sha256}`));
65
+ }
66
+ if (body.updatePolicy === 'follow') parts.push('--follow');
67
+ if (body.dataDir === false) parts.push('--no-data-dir');
68
+ if (body.perUserChild) parts.push('--per-user-child');
69
+ if (body.bridge) parts.push('--bridge');
70
+ return parts.join(' ');
71
+ }
72
+
73
+ function describeSource(source) {
74
+ if (source.type === 'npm') return `${source.pkg}@${source.version}`;
75
+ if (source.type === 'mcpb') return `${source.name}@${source.version} (mcpb ${source.serverType}, sha256 ${source.sha256})`;
76
+ return `${source.repo}@${source.commit}${source.subdir ? ` (${source.subdir})` : ''}`;
77
+ }
78
+
79
+ /**
80
+ * @param {import('./prompt.mjs').Prompter} prompter
81
+ * @param {(method: string, path: string, body?: unknown) => Promise<any>} api
82
+ * @param {{ stderr?: { write(s: string): unknown }, uploadBundle?: (file: string) => Promise<any> }} [opts]
83
+ * `uploadBundle` is index.mjs's raw `PUT /api/v1/bundles` (the one non-JSON
84
+ * request), passed in like `api` so a test can hand over a recorder.
85
+ * @returns {Promise<{ registration: any, body: object, secretsStored: string[] }>}
86
+ */
87
+ export async function runWizard(prompter, api, { stderr = process.stderr, uploadBundle } = {}) {
88
+ const say = (s = '') => stderr.write(`${s}\n`);
89
+
90
+ // --- source, previewed before anything else is asked -------------------
91
+ // The preview is the first call because everything after it (the slug
92
+ // suggestion, the env rows, the bins) is read off its answer, and a source
93
+ // that does not resolve should fail here, before a single other question.
94
+ let source;
95
+ let preview;
96
+ for (;;) {
97
+ // APPENDED, never inserted: the menu is answered by position in every
98
+ // scripted run (wizard.test.ts), and by habit at a terminal.
99
+ const kind = await prompter.choose('Where does the MCP come from?', [
100
+ { value: 'npm', label: 'an npm package' },
101
+ { value: 'github', label: 'a GitHub repository' },
102
+ { value: 'mcpb', label: 'an MCPB bundle (.mcpb file or URL)' },
103
+ ]);
104
+ try {
105
+ if (kind === 'npm') {
106
+ source = parseNpmSpec(await prompter.ask('Package (name[@version|dist-tag])'));
107
+ } else if (kind === 'mcpb') {
108
+ // A path is uploaded NOW, before the preview, because the preview
109
+ // can only name a bundle the gateway holds; the summary it answers
110
+ // (type, signature presence, the config this host cannot deliver) is
111
+ // printed before anything else is asked. A refused upload re-asks
112
+ // the source like a refused preview does. The body then names the
113
+ // HASH — the URL form stays a URL, the gateway fetches it.
114
+ const spec = parseMcpbSpec(await prompter.ask('Bundle (.mcpb path, https URL, or sha256:<hex> of one already uploaded)'));
115
+ if (spec.kind === 'url') source = { type: 'mcpb', url: spec.url };
116
+ else if (spec.kind === 'sha256') source = { type: 'mcpb', sha256: spec.sha256 };
117
+ else {
118
+ if (!uploadBundle) throw new Error('this wizard was started without an uploader; pass --mcpb to `register` instead');
119
+ say('Uploading…');
120
+ const view = await uploadBundle(spec.path);
121
+ for (const line of bundleSummaryLines(view)) say(line);
122
+ source = { type: 'mcpb', sha256: view.sha256 };
123
+ }
124
+ } else {
125
+ source = parseGithubSpec(await prompter.ask('Repository (owner/repo[#ref])'));
126
+ const subdir = await prompter.ask('Subdirectory (monorepos; blank for the root)', { default: '' });
127
+ const build = await prompter.ask('Build command (blank for none; installs never run scripts)', { default: '' });
128
+ if (subdir) source.subdir = subdir;
129
+ if (build) source.build = build;
130
+ }
131
+ say('Resolving…');
132
+ preview = await api('POST', '/api/v1/registrations/preview', { source });
133
+ break;
134
+ } catch (error) {
135
+ say(` ${error.message}`);
136
+ say(' Try again.');
137
+ }
138
+ }
139
+ printPreview(say, preview);
140
+
141
+ // --- identity -----------------------------------------------------------
142
+ // A bundle's input names no package; the manifest's name is on the pin.
143
+ const sourceName = source.type === 'npm' ? source.pkg : source.type === 'mcpb' ? preview.source.name : source.repo;
144
+ let slug;
145
+ for (;;) {
146
+ slug = await prompter.ask('Slug (the URL path: /c/<account>/<slug>/mcp)', { default: suggestSlug(sourceName) });
147
+ if (slug) break;
148
+ say(' a slug is required');
149
+ }
150
+ const displayName = await prompter.ask('Display name', { default: preview.package?.name ?? slug });
151
+
152
+ // --- env ----------------------------------------------------------------
153
+ /** @type {Record<string, string | {secret: string}>} */
154
+ const env = {};
155
+ /** @type {string[]} */
156
+ const secretsStored = [];
157
+ /** @type {string[] | null} — fetched once, the first time a row asks */
158
+ let existingSecrets = null;
159
+ const secretNames = async () => {
160
+ if (existingSecrets === null) {
161
+ const listed = await api('GET', '/api/v1/secrets');
162
+ existingSecrets = listed.secrets.map((s) => s.name);
163
+ }
164
+ return existingSecrets;
165
+ };
166
+
167
+ /**
168
+ * One env row. Returns when the row is settled: bound, or skipped when
169
+ * that is allowed. A required row re-asks, because the preview said the
170
+ * MCP will not start without it and a create that omits it is a
171
+ * registration that fails at its first spawn.
172
+ */
173
+ async function envRow(name, { required, secret: secretShaped, help }) {
174
+ const tag = required ? 'required' : 'optional';
175
+ for (;;) {
176
+ say();
177
+ say(`${name} (${tag}${secretShaped ? ', a secret' : ''})${help ? ` — ${help}` : ''}`);
178
+ const how = await prompter.choose(`How should ${name} be set?`, [
179
+ { value: 'literal', label: 'a literal value (stored in plaintext, shown on every GET)' },
180
+ { value: 'secret', label: 'an existing secret' },
181
+ { value: 'new', label: 'a new secret (entered now, without echo)' },
182
+ { value: 'skip', label: required ? 'skip (not allowed: required)' : 'skip' },
183
+ ]);
184
+ if (how === 'skip') {
185
+ if (!required) return;
186
+ say(` ${name} is required`);
187
+ continue;
188
+ }
189
+ if (how === 'literal') {
190
+ const value = await prompter.ask(`Value for ${name}`);
191
+ if (value === '' && required) {
192
+ say(` ${name} is required`);
193
+ continue;
194
+ }
195
+ if (secretShaped) say(' note: the metadata marks this a secret; a literal is stored in plaintext');
196
+ env[name] = value;
197
+ return;
198
+ }
199
+ if (how === 'secret') {
200
+ const names = await secretNames();
201
+ if (names.length === 0) {
202
+ say(' no secrets stored yet — choose "a new secret"');
203
+ continue;
204
+ }
205
+ const picked = await prompter.choose('Which secret?', names.map((n) => ({ value: n })));
206
+ env[name] = { secret: picked };
207
+ return;
208
+ }
209
+ // 'new': stored IMMEDIATELY, before the create, so the reference the
210
+ // body carries names a secret that exists. The value goes on its own
211
+ // PUT and nowhere else — not the body, not the epilogue.
212
+ const secretName = await prompter.ask('Secret name', { default: name });
213
+ const value = await prompter.secret(`Value for ${secretName}`);
214
+ if (value === '') {
215
+ say(' an empty secret is not stored');
216
+ continue;
217
+ }
218
+ // Sealed to the account key on the way out — the SAME path `secret
219
+ // set` takes (secrets.mjs), so the wizard cannot store a value the
220
+ // gateway can read. A refused PUT re-asks the row rather than ending
221
+ // the wizard: the value never reached the store, so nothing is
222
+ // half-done, and the person can pick another way (an existing secret,
223
+ // a literal, skip).
224
+ try {
225
+ await storeSecretSealed(api, secretName, value, { stderr });
226
+ } catch (error) {
227
+ say(` ${error.message}`);
228
+ say(` ${secretName} was not stored.`);
229
+ continue;
230
+ }
231
+ secretsStored.push(secretName);
232
+ if (existingSecrets && !existingSecrets.includes(secretName)) existingSecrets.push(secretName);
233
+ env[name] = { secret: secretName };
234
+ return;
235
+ }
236
+ }
237
+
238
+ if (preview.env.length > 0) {
239
+ say();
240
+ say(`Environment proposed by the package's ${preview.envSource ?? 'metadata'}:`);
241
+ }
242
+ for (const row of preview.env) await envRow(row.name, row);
243
+ while (await prompter.confirm('Add another environment variable?', false)) {
244
+ const name = await prompter.ask('Variable name');
245
+ if (!name) continue;
246
+ await envRow(name, { required: false, secret: false });
247
+ }
248
+
249
+ // --- dependencies -------------------------------------------------------
250
+ // Not asked for a bundle: the API refuses dependencies on one ("ships its
251
+ // own tree"), and asking a question whose every answer is refused is
252
+ // worse than saying so once.
253
+ const dependencies = [];
254
+ const isBundle = source.type === 'mcpb';
255
+ say();
256
+ if (isBundle) say('A bundle ships its own tree: no dependencies, and no version channel to follow.');
257
+ else {
258
+ say('Dependencies are tools the MCP shells out to that are not in its npm tree');
259
+ say(`(the flag form: ${DEP_GRAMMAR}).`);
260
+ }
261
+ while (!isBundle && (await prompter.confirm('Add a dependency?', false))) {
262
+ const kind = await prompter.choose('Dependency kind', [
263
+ { value: 'github-release', label: 'a GitHub release asset (a binary on the PATH)' },
264
+ { value: 'npm', label: 'an npm package (its bins on the PATH)' },
265
+ ]);
266
+ if (kind === 'npm') {
267
+ const { pkg, version } = parseNpmSpec(await prompter.ask('Package (name[@version|dist-tag])'));
268
+ dependencies.push({ kind: 'npm', pkg, ...(version ? { version } : {}) });
269
+ } else {
270
+ const repo = await prompter.ask('Repository (owner/repo)');
271
+ const tag = await prompter.ask('Release tag', { default: 'latest' });
272
+ const asset = await prompter.ask('Asset name (exact, or a glob with * matching exactly one)');
273
+ const bins = (await prompter.ask('Executables to extract (comma-separated)'))
274
+ .split(',')
275
+ .map((b) => b.trim())
276
+ .filter(Boolean);
277
+ const sha256 = await prompter.ask('sha256 of the asset (only when GitHub reports no digest; blank otherwise)', {
278
+ default: '',
279
+ });
280
+ dependencies.push({ kind: 'github-release', repo, tag, asset, bin: bins, ...(sha256 ? { sha256 } : {}) });
281
+ }
282
+ }
283
+ if (dependencies.length > 0 && (await prompter.confirm('Re-resolve the preview with these dependencies?', false))) {
284
+ try {
285
+ preview = await api('POST', '/api/v1/registrations/preview', { source, dependencies });
286
+ printPreview(say, preview);
287
+ } catch (error) {
288
+ say(` ${error.message}`);
289
+ }
290
+ }
291
+
292
+ // --- options ------------------------------------------------------------
293
+ // No runtime question: there is one tier since the isolated one was retired
294
+ // (2026-09-11), and no egress question either — `requireEgressPolicy` asks
295
+ // it of a TENANT, and this CLI authenticates with MCP_HOST_ADMIN_TOKEN, so
296
+ // its caller is always the operator (index.mjs `buildRegisterBody` says the
297
+ // whole of it). The portal is where a tenant is asked.
298
+ //
299
+ // Same for `follow`: the API refuses it on a bundle (no channel to follow).
300
+ const follow = isBundle ? false : await prompter.confirm('Follow the latest version (the daily cron moves the pin)?', false);
301
+ // Default YES, because the server's default is yes: since
302
+ // docs/GLOBAL-REGISTRATIONS.md §5 a registration that says nothing GETS a
303
+ // persistent HOME, and a question whose default contradicted the server's
304
+ // would hand somebody the opposite of what they read. The ANSWER is sent
305
+ // only when it is "no" — see the body below.
306
+ const dataDir = await prompter.confirm('Persistent data directory (HOME on the runner volume)?', true);
307
+ const perUserChild = await prompter.confirm('One child per connected user (per-user credentials)?', false);
308
+ // Asked AFTER dataDir on purpose: a bridged MCP's fetchproxy identity lives
309
+ // under its HOME, so without persistence the pair code changes on every cold
310
+ // start and the extension has to be re-paired each time
311
+ // (docs/BROWSER-BRIDGE.md). The prompt says so rather than letting someone
312
+ // discover it later, and the default follows what was just answered.
313
+ const bridge = await prompter.confirm(
314
+ dataDir
315
+ ? 'Route this MCP\'s fetches through your browser (the fetchproxy bridge)?'
316
+ : 'Route this MCP\'s fetches through your browser (the fetchproxy bridge)? Without a persistent data directory its pair code changes on every cold start',
317
+ false,
318
+ );
319
+
320
+ // The body `register` would have built from the equivalent flags: defaults
321
+ // omitted, `dependencies` only when there are any. No `account` either — the
322
+ // wizard never asks for one, and an absent account is the caller's own.
323
+ const body = {
324
+ slug,
325
+ displayName,
326
+ source,
327
+ env,
328
+ enabledTools: null,
329
+ ...(follow ? { updatePolicy: 'follow' } : {}),
330
+ // Sent only when DECLINED. Omitted means "the server decides", and it
331
+ // decides yes; a `false` is the one answer the body has to carry, or the
332
+ // question would be decorative.
333
+ ...(dataDir ? {} : { dataDir: false }),
334
+ ...(perUserChild ? { perUserChild: true } : {}),
335
+ ...(bridge ? { bridge: true } : {}),
336
+ ...(dependencies.length > 0 ? { dependencies } : {}),
337
+ };
338
+ if (preview.package && preview.package.bins.length > 1) {
339
+ const bin = await prompter.choose('The package has several bins; which one is the MCP?', preview.package.bins.map((b) => ({ value: b })));
340
+ body.command = { bin };
341
+ }
342
+
343
+ // --- summary + confirm --------------------------------------------------
344
+ say();
345
+ say('Summary');
346
+ say(` slug: ${slug}`);
347
+ say(` name: ${displayName}`);
348
+ say(` source: ${describeSource(preview.source)}`);
349
+ for (const [name, value] of Object.entries(env)) {
350
+ say(` env: ${name} = ${typeof value === 'string' ? value : `{secret ${value.secret}}`}`);
351
+ }
352
+ for (const dep of dependencies) say(` dep: ${depSpec(dep)}`);
353
+ if (follow) say(' follow latest: yes');
354
+ say(` data dir: ${dataDir ? 'yes' : 'no'}`);
355
+ if (perUserChild) say(' per-user child: yes');
356
+ if (bridge) say(` browser bridge: yes${dataDir ? '' : ' (no data dir — the pair code changes on every cold start)'}`);
357
+ say();
358
+ say('Equivalent command:');
359
+ say(` ${equivalentCommand(body)}`);
360
+ say();
361
+
362
+ // Said on every exit that is not a created registration — a declined
363
+ // confirm AND a refused create — because the secrets were stored when they
364
+ // were entered, and a person who was never told would not know to `rm`.
365
+ const sayStoredSecrets = () => {
366
+ if (secretsStored.length === 0) return;
367
+ say(`The secret${secretsStored.length > 1 ? 's' : ''} already stored stay stored: ${secretsStored.join(', ')}`);
368
+ say('(remove with `mcp-host secret rm <NAME>`).');
369
+ };
370
+
371
+ if (!(await prompter.confirm('Register it?', true))) {
372
+ say('Not registered.');
373
+ sayStoredSecrets();
374
+ return { registration: null, body, secretsStored };
375
+ }
376
+
377
+ let registration;
378
+ try {
379
+ registration = await api('POST', '/api/v1/registrations', body);
380
+ } catch (error) {
381
+ // Not retried: a refused create is usually the slug or the source, and
382
+ // the answers that led here are the equivalent command printed above —
383
+ // edit it and run that. Rethrown so the CLI exits 1 as `register` would.
384
+ say(` ${error.message}`);
385
+ say('Not registered.');
386
+ sayStoredSecrets();
387
+ throw error;
388
+ }
389
+ say();
390
+ say('Add to claude.ai: Settings -> Connectors -> Add custom connector ->');
391
+ say(` URL: ${registration.shortConnectUrl ?? registration.connectUrl}`);
392
+ say(' Sign in there and approve the connection — no key to paste.');
393
+ say();
394
+ say('Next time, the same registration without the questions:');
395
+ say(` ${equivalentCommand(body)}`);
396
+ return { registration, body, secretsStored };
397
+ }
398
+
399
+ function printPreview(say, preview) {
400
+ say();
401
+ say(`Pinned: ${describeSource(preview.source)}`);
402
+ if (preview.source.type === 'npm') say(` integrity: ${preview.source.integrity ?? '(none recorded — installs unverified)'}`);
403
+ if (preview.package) {
404
+ if (preview.package.description) say(` ${preview.package.description}`);
405
+ say(` bins: ${preview.package.bins.length > 0 ? preview.package.bins.join(', ') : '(none)'}`);
406
+ }
407
+ for (const dep of preview.dependencies ?? []) {
408
+ say(
409
+ dep.kind === 'npm'
410
+ ? ` dependency: npm ${dep.pkg}@${dep.version}`
411
+ : ` dependency: ${dep.repo}@${dep.tag} ${dep.asset}${dep.sha256 ? ` sha256 ${dep.sha256}` : ''}`,
412
+ );
413
+ }
414
+ for (const note of preview.notes ?? []) say(` note: ${note}`);
415
+ for (const skipped of preview.skipped ?? []) say(` skipped ${skipped.name}: ${skipped.reason}`);
416
+ }