@chrischall/mcp-host-cli 0.0.0-stage → 2.33.0

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/index.mjs ADDED
@@ -0,0 +1,1333 @@
1
+ #!/usr/bin/env node
2
+ // mcp-host admin CLI — a thin client for the gateway control API.
3
+ //
4
+ // export MCP_HOST_URL=https://mcp.nullnet.app
5
+ // export MCP_HOST_ADMIN_TOKEN=...
6
+ //
7
+ // mcp-host register # no flags on a terminal: the interactive wizard (also `mcp-host wizard`)
8
+ // mcp-host register --slug setlist --npm setlist-mcp --secret-env SETLIST_API_KEY=SETLIST_API_KEY
9
+ // mcp-host register --slug thing --github owner/repo#main --build "npm run build"
10
+ // mcp-host register --slug fresh --npm some-pkg --follow # daily cron follows the latest version
11
+ // mcp-host register --slug stateful --npm some-pkg --no-data-dir # opt OUT of the persistent HOME a new registration now gets
12
+ // mcp-host register --slug kia --npm kiaaccess-mcp --per-user-child \
13
+ // --auth-flow ./kia-flow.json # a login that answers back: steps, prompts, capture
14
+ // mcp-host register --slug gog --npm gogcli-mcp \
15
+ // --dep 'github:openclaw/gogcli@v0.37.0:gogcli_*_linux_amd64.tar.gz#gog' # a binary on the child's PATH
16
+ // [--dep 'npm:<pkg>[@<version>]'] [--dep-sha256 '<asset>=<64hex>'] # sha256 only when GitHub has no digest
17
+ // mcp-host register --slug memory --npm @modelcontextprotocol/server-memory \
18
+ // --env 'MEMORY_FILE_PATH=~/memory.jsonl' # a leading ~ (or ${HOME}) resolves to this registration's actual HOME at spawn time
19
+ // mcp-host register --slug thing --pypi markitdown-mcp[==0.0.1a4] # a PyPI package: pinned to a version and its wheel's sha256
20
+ // mcp-host register --slug thing --mcpb ./thing.mcpb # an MCPB bundle: uploaded (PUT /api/v1/bundles), then pinned by its sha256
21
+ // mcp-host register --slug thing --mcpb https://…/thing.mcpb # fetched by the gateway; --mcpb sha256:<hex> names one already uploaded
22
+ // mcp-host bundles upload ./thing.mcpb # upload alone; prints the sha256 to register later or reuse across registrations
23
+ // mcp-host preview --npm <pkg[@version]> | --github <owner/repo[#ref]> | --pypi <pkg[==version]> | --mcpb … [--dep …] # resolve, write nothing (a --mcpb path still uploads)
24
+ // mcp-host list | get <id> | status <id> | logs <id>
25
+ // mcp-host set <id> --data-dir | --no-data-dir # persistent HOME, after the fact
26
+ // mcp-host set <id> --state-store snapshot # keep that HOME in R2, not on one machine's volume
27
+ // mcp-host set <id> --dep … | --no-deps # replace or clear the dependencies
28
+ // mcp-host set <id> --deny destructive|non-read-only | --allow-all # the tool policy, judged at the gateway (restarts nothing)
29
+ // mcp-host set <id> --ask send_mail,delete_event | --no-ask # hold named tools for the owner's approval
30
+ // mcp-host set <id> --tools a,b | --all-tools # narrow the allowlist, or lift it
31
+ // mcp-host approvals [ls] [--status all] | approvals approve <apr id> | approvals refuse <apr id>
32
+ // mcp-host me | me set --push-tenant-alerts on|off # whether alerts about your MCPs are pushed to you
33
+ // mcp-host profile ls <id> | profile add <id> --label work --secret-env VAR=SECRET | profile set <id> <rpf id> … | profile rm <id> <rpf id>
34
+ // mcp-host connections rename <prn id> <label> | connections default <prn id> | connections undefault <prn id>
35
+ // mcp-host prewarm <id> | restart <id> | rm <id>
36
+ // mcp-host build <id> [--force] [--wait] # ask the builder for a prebuilt install tree now (needs BUILDER_URL on the gateway); --force rebuilds a ready one (operator-only; a 403 from anyone else), --wait holds on for the manifest
37
+ // RUNNER_KEY=... mcp-host builder-key # LEGACY: HMAC(master, 'builder'); the builder now holds BUILDER_KEY
38
+ // mcp-host secret set NAME --value ... | secret ls | secret rm NAME
39
+ // # sealed to the account's runner key BEFORE it leaves this process (docs/SEALED-SECRETS.md);
40
+ // # prints the fingerprint to compare later. --legacy sends the deprecated gateway-readable body.
41
+ // mcp-host secret set NAME # on a terminal: prompts, without echo
42
+ // mcp-host secret set KIA_PASSWORD --from-env # reads $KIA_PASSWORD
43
+ // mcp-host secret set KIA_PASSWORD --from-env MY_PW # reads $MY_PW
44
+ // mcp-host secret fingerprint NAME [--value v | --from-env [VAR]] # "match" / "NO MATCH", computed locally; exit 0/1
45
+ // mcp-host connector [--account slug] [--json] # the account's ONE connect URL, /c/<account>/all/mcp (URL alone on stdout)
46
+ // mcp-host connector connections | revoke <grant id> | revoke-all [--account slug] # your aggregate connections, and ending them
47
+ // mcp-host set <id> --aggregate inline|search|exclude # how it takes part in that connector (restarts nothing)
48
+ // mcp-host marketplace [rotate|revoke] [--account slug]
49
+ // mcp-host bridge [ls|revoke <id>] [--account slug] # a browser connects from its extension (D18)
50
+ // mcp-host infra [--days N] (super-admin: machines, the shared pool, resource totals)
51
+ // mcp-host infra sync # bring the shared pool's machine set in step with Fly (#322 phase 4)
52
+ // mcp-host infra drain <machine id> | undrain <machine id> # take a pool machine out of / back into placement
53
+ //
54
+ // mcp-host help # EVERY command: operations.mjs is the list, one row per
55
+ // # control-API operation, held to openapi.json by tests/api-parity.test.ts
56
+ //
57
+ // --insecure # allow an http:// MCP_HOST_URL (a development gateway). Without it a
58
+ // # non-https URL is refused: every request carries the admin bearer.
59
+
60
+ import { readFile } from 'node:fs/promises';
61
+ import {
62
+ AGGREGATE_MODES,
63
+ INSECURE_FLAG,
64
+ approvalLines,
65
+ MCPB_USAGE,
66
+ buildDependencies,
67
+ buildEnvMap,
68
+ bundleSummaryLines,
69
+ deriveMachineKey,
70
+ firstFlag,
71
+ flagPresent,
72
+ insecureGatewayRefusal,
73
+ mergeToolPolicy,
74
+ parseArgs,
75
+ parseGithubSpec,
76
+ parseMcpbSpec,
77
+ parseNpmSpec,
78
+ parseOnOff,
79
+ parsePypiSpec,
80
+ parseToolList,
81
+ readJsonDocument,
82
+ readSecretEnv,
83
+ toolPolicyChange,
84
+ wantsWizard,
85
+ } from './lib.mjs';
86
+ import { runLogin } from './login.mjs';
87
+ import { flagName, helpFor, helpText, matchGeneric, pathParams, usageFor } from './operations.mjs';
88
+ import { makePrompter } from './prompt.mjs';
89
+ import { compareFingerprint, storeSecretSealed } from './secrets.mjs';
90
+ import { runWizard } from './wizard.mjs';
91
+
92
+ const BASE = (process.env.MCP_HOST_URL ?? '').replace(/\/$/, '');
93
+ const TOKEN = process.env.MCP_HOST_ADMIN_TOKEN ?? '';
94
+
95
+ /**
96
+ * `--insecure` is read here, off the whole command line, and REMOVED before
97
+ * any command parses its own flags — it is a property of where this process
98
+ * is talking, not of the verb, and every verb reaches the same gateway.
99
+ *
100
+ * Removing it is what makes that safe: `parseArgs` gives an undeclared flag
101
+ * the NEXT token as its value, so an `--insecure` left in `rest` would eat
102
+ * the flag after it (`register --insecure --slug x` would bind `--slug`).
103
+ * The one thing it costs is a flag VALUE spelled exactly `--insecure`, which
104
+ * is stripped wherever it appears — a secret with that value goes in by
105
+ * `--from-env` or stdin, which is the recommended route for a secret anyway.
106
+ */
107
+ const INSECURE = process.argv.slice(2).includes(INSECURE_FLAG);
108
+ const ARGV = process.argv.slice(2).filter((arg) => arg !== INSECURE_FLAG);
109
+ /**
110
+ * `--help` is read the same way and for the same reason: anywhere on the
111
+ * line, before any command parses, because `parseArgs` would otherwise bind
112
+ * the next token to it (`list --help` was `--help needs a value`). The same
113
+ * cost too — a flag VALUE spelled exactly `--help` goes in by `--from-env` or
114
+ * stdin. `-h` is honoured only as the first word, where it cannot be a value.
115
+ */
116
+ const HELP = ARGV.includes('--help') || ARGV[0] === '-h';
117
+
118
+ /**
119
+ * A non-2xx from the control API, as an Error the caller may catch. Carries
120
+ * the status so a caller can tell a refusal from an outage; the message is
121
+ * the one line `fail` prints, so the two paths say the same thing.
122
+ */
123
+ class ApiError extends Error {
124
+ constructor(method, path, status, detail) {
125
+ super(`${method} ${path} -> HTTP ${status}: ${detail}`);
126
+ this.name = 'ApiError';
127
+ this.status = status;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * One request to the control API; a non-2xx THROWS an ApiError. This is the
133
+ * variant the wizard gets, because the wizard has retry loops and a
134
+ * "your secrets already stored stay stored" epilogue that a `process.exit`
135
+ * mid-conversation would skip — with `api` (below) passed in, none of its
136
+ * catch blocks could ever run for an HTTP error.
137
+ */
138
+ async function apiRequest(method, path, body) {
139
+ requireEnv();
140
+ const res = await fetch(`${BASE}${path}`, {
141
+ method,
142
+ headers: {
143
+ authorization: `Bearer ${TOKEN}`,
144
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
145
+ },
146
+ body: body !== undefined ? JSON.stringify(body) : undefined,
147
+ });
148
+ const text = await res.text();
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(text);
152
+ } catch {
153
+ parsed = { raw: text };
154
+ }
155
+ if (!res.ok) throw new ApiError(method, path, res.status, parsed.error ?? text);
156
+ return parsed;
157
+ }
158
+
159
+ /** `apiRequest` for the one-shot commands: a non-2xx is `fail()`, exit 1. */
160
+ async function api(method, path, body) {
161
+ try {
162
+ return await apiRequest(method, path, body);
163
+ } catch (error) {
164
+ if (error instanceof ApiError) return fail(error.message);
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * `PUT /api/v1/bundles` with a .mcpb read off disk — the ONE request this CLI
171
+ * makes that is not JSON. The body is the raw bytes under an explicit
172
+ * Content-Length, because the gateway judges the cap on the header before it
173
+ * reads a byte and refuses a body without one; a chunked upload would be
174
+ * refused, not capped. The whole file is read into memory first (64 MiB at
175
+ * most, by the same cap) so the length is known and the bytes hashed by the
176
+ * gateway are the bytes on disk. THROWS an ApiError like `apiRequest`, for
177
+ * the wizard's retry loop; the flags path wraps it in `fail`.
178
+ *
179
+ * @param {string} file
180
+ */
181
+ async function uploadBundle(file) {
182
+ requireEnv();
183
+ let bytes;
184
+ try {
185
+ bytes = await readFile(file);
186
+ } catch (error) {
187
+ throw new Error(`--mcpb: cannot read ${file}: ${error.message}`);
188
+ }
189
+ const res = await fetch(`${BASE}/api/v1/bundles`, {
190
+ method: 'PUT',
191
+ headers: {
192
+ authorization: `Bearer ${TOKEN}`,
193
+ 'content-type': 'application/octet-stream',
194
+ 'content-length': String(bytes.length),
195
+ },
196
+ body: bytes,
197
+ });
198
+ const text = await res.text();
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(text);
202
+ } catch {
203
+ parsed = { raw: text };
204
+ }
205
+ if (!res.ok) throw new ApiError('PUT', '/api/v1/bundles', res.status, parsed.error ?? text);
206
+ return parsed;
207
+ }
208
+
209
+ /** `uploadBundle` for the one-shot commands: a refusal is `fail()`, exit 1; the summary goes to stderr. */
210
+ async function uploadBundleOrFail(file) {
211
+ let view;
212
+ try {
213
+ view = await uploadBundle(file);
214
+ } catch (error) {
215
+ // An ApiError and an unreadable file both end here: the one line is the message.
216
+ return fail(error.message);
217
+ }
218
+ for (const line of bundleSummaryLines(view)) console.error(line);
219
+ return view;
220
+ }
221
+
222
+ function fail(message) {
223
+ console.error(`mcp-host: ${message}`);
224
+ process.exit(1);
225
+ }
226
+
227
+ /**
228
+ * The env check every request makes — `apiRequest` and `uploadBundle` call
229
+ * it rather than repeating it — pulled forward for the interactive paths
230
+ * too: a wizard that asked nine questions and THEN said "set MCP_HOST_URL"
231
+ * would have wasted every one of them.
232
+ *
233
+ * Two things, in this order. The variables have to be SET; and the URL has
234
+ * to be one the admin bearer may be sent to, which is https unless
235
+ * `--insecure` says otherwise (`insecureGatewayRefusal` in lib.mjs carries
236
+ * the reasoning). Order matters for the sentence a person reads: an unset
237
+ * MCP_HOST_URL is "set it", never a complaint about the scheme of ''.
238
+ */
239
+ function requireEnv() {
240
+ if (!BASE || !TOKEN) fail('set MCP_HOST_URL and MCP_HOST_ADMIN_TOKEN in the environment');
241
+ const refusal = insecureGatewayRefusal(BASE, INSECURE);
242
+ if (refusal) fail(refusal);
243
+ }
244
+
245
+ /**
246
+ * `mcp-host wizard`, and `register` with no flags on a terminal. Refused on
247
+ * a pipe rather than attempted: a script that hit this would hang on a
248
+ * prompt nobody is reading, and the flags it should have passed are the
249
+ * answer. The wizard prints the created registration on stdout exactly as
250
+ * `register` does, so `mcp-host wizard | jq` still works once the questions
251
+ * (all on stderr) are answered.
252
+ */
253
+ async function wizard() {
254
+ if (!process.stdin.isTTY) fail('the wizard needs a terminal; pass flags instead');
255
+ requireEnv();
256
+ const prompter = makePrompter();
257
+ try {
258
+ // The THROWING variant: the wizard handles a refused preview, secret or
259
+ // create itself (re-asking, or saying what stayed stored) and rethrows
260
+ // only what it cannot recover from, which lands here as `fail`.
261
+ const { registration } = await runWizard(prompter, apiRequest, { uploadBundle });
262
+ if (registration) print(registration);
263
+ } catch (error) {
264
+ if (error instanceof ApiError) fail(error.message);
265
+ throw error;
266
+ } finally {
267
+ prompter.close();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * The unpinned source named by `--npm`/`--github` (+ `--subdir`/`--build`) or
273
+ * `--mcpb`, shared by `register` and `preview` so the two cannot read the
274
+ * flags differently — a preview that resolved a different source from the
275
+ * one a create would send would be worse than no preview.
276
+ *
277
+ * Async for one reason: `--mcpb <path>` UPLOADS. The gateway pins a bundle
278
+ * by the sha256 of bytes it holds, so a file on this disk has to reach it
279
+ * before either command can name it — and that is true of `preview` too,
280
+ * which is the one way a preview writes (the row the upload records; the
281
+ * URL form writes the same row gateway-side). The summary the upload answers
282
+ * is printed here, on stderr, because it is the last thing to read before
283
+ * the create goes out. The other two `--mcpb` forms send what was typed and
284
+ * the gateway does the fetch or the lookup.
285
+ *
286
+ * The refusals for what a bundle cannot carry are the API's own sentences,
287
+ * so a script matching on them sees one wording whichever side answers.
288
+ */
289
+ async function sourceFromFlags(parsed) {
290
+ const npmSpec = firstFlag(parsed, 'npm');
291
+ const githubSpec = firstFlag(parsed, 'github');
292
+ const mcpbSpec = firstFlag(parsed, 'mcpb');
293
+ const pypiSpec = firstFlag(parsed, 'pypi');
294
+ const named = [npmSpec, githubSpec, mcpbSpec, pypiSpec].filter((s) => s !== undefined).length;
295
+ if (named !== 1) {
296
+ fail(
297
+ `pass exactly one of --npm <pkg[@version]>, --github <owner/repo[#ref]>, --pypi <pkg[==version]> or ${MCPB_USAGE}`,
298
+ );
299
+ }
300
+ if (pypiSpec !== undefined) {
301
+ // The refusals are the API's own sentences, so a script matching on them
302
+ // sees one wording whichever side answers — the rule --mcpb already keeps.
303
+ if (firstFlag(parsed, 'subdir') !== undefined || firstFlag(parsed, 'build') !== undefined) {
304
+ fail('--subdir and --build are for --github; a PyPI source installs a venv from its resolved requirements');
305
+ }
306
+ if ((parsed.flags['dep'] ?? []).length > 0 || (parsed.flags['dep-sha256'] ?? []).length > 0) {
307
+ fail('a PyPI source installs a venv from its own resolved requirements; dependencies are not supported on it');
308
+ }
309
+ try {
310
+ return parsePypiSpec(pypiSpec);
311
+ } catch (error) {
312
+ return fail(error.message);
313
+ }
314
+ }
315
+ if (mcpbSpec !== undefined) {
316
+ if (firstFlag(parsed, 'subdir') !== undefined || firstFlag(parsed, 'build') !== undefined) {
317
+ fail('--subdir and --build are for --github; a bundle is unpacked and launched from its manifest');
318
+ }
319
+ if ((parsed.flags['dep'] ?? []).length > 0 || (parsed.flags['dep-sha256'] ?? []).length > 0) {
320
+ fail('an MCPB bundle ships its own tree; dependencies are not supported on it');
321
+ }
322
+ if (firstFlag(parsed, 'bin') !== undefined) {
323
+ fail("an MCPB bundle's command comes from its manifest; --bin is for npm and GitHub sources");
324
+ }
325
+ if (parsed.booleans.has('follow')) {
326
+ fail('an MCPB bundle has no channel to follow; upload a new bundle and PUT the source instead of updatePolicy: follow');
327
+ }
328
+ let spec;
329
+ try {
330
+ spec = parseMcpbSpec(mcpbSpec);
331
+ } catch (error) {
332
+ return fail(error.message);
333
+ }
334
+ if (spec.kind === 'url') return { type: 'mcpb', url: spec.url };
335
+ if (spec.kind === 'sha256') return { type: 'mcpb', sha256: spec.sha256 };
336
+ const view = await uploadBundleOrFail(spec.path);
337
+ return { type: 'mcpb', sha256: view.sha256 };
338
+ }
339
+ const source = npmSpec ? parseNpmSpec(npmSpec) : parseGithubSpec(githubSpec);
340
+ if (source.type === 'github') {
341
+ const subdir = firstFlag(parsed, 'subdir');
342
+ const build = firstFlag(parsed, 'build');
343
+ if (subdir) source.subdir = subdir;
344
+ if (build) source.build = build;
345
+ }
346
+ return source;
347
+ }
348
+
349
+ /** Repeated `--dep` (+ `--dep-sha256`) as the API's dependency input; [] when none. */
350
+ function dependenciesFromFlags(parsed) {
351
+ try {
352
+ return buildDependencies(parsed.flags['dep'] ?? [], parsed.flags['dep-sha256'] ?? []);
353
+ } catch (error) {
354
+ return fail(error.message);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * `--data-dir` / `--no-data-dir` as the create body's `dataDir`, or NOTHING
360
+ * when neither was passed.
361
+ *
362
+ * Three states rather than two, because the server's absent-value default is
363
+ * now `true` (docs/GLOBAL-REGISTRATIONS.md §5) and a CLI that collapsed
364
+ * "unsaid" into `false` would freeze the old default into every script that
365
+ * never mentioned the flag.
366
+ */
367
+ function dataDirFromFlags(parsed) {
368
+ const on = parsed.booleans.has('data-dir');
369
+ const off = parsed.booleans.has('no-data-dir');
370
+ if (on && off) fail('pass one of --data-dir or --no-data-dir, not both');
371
+ if (!on && !off) return {};
372
+ return { dataDir: on };
373
+ }
374
+
375
+ /**
376
+ * The create body from `register`'s flags. Pulled out of the command so the
377
+ * wizard can build the identical body and so the flags-only path stays
378
+ * byte-identical: every key below is exactly what `register` always sent, in
379
+ * the order it sent it, and `dependencies` is omitted when there are none so
380
+ * a registration without `--dep` serialises as it did before the flag existed.
381
+ *
382
+ * There is no `runtime` here and never was: every registration runs on the
383
+ * shared pool, and since the isolated tier was retired (2026-09-11) that is
384
+ * the only runtime the API accepts from anybody.
385
+ *
386
+ * There is no `egress` either, and that is a fact about WHO this tool is
387
+ * rather than an omission. `requireEgressPolicy` refuses a create with no
388
+ * policy from a TENANT and leaves the operator's own rows unrestricted — and
389
+ * this CLI authenticates with `MCP_HOST_ADMIN_TOKEN` and nothing else, so its
390
+ * caller is always the operator, `--account` included (an operator scoped into
391
+ * somebody's account is still the operator deciding). A tenant's create is
392
+ * made in the portal, which asks. If this tool ever grows a session login, it
393
+ * grows the question with it.
394
+ */
395
+ async function buildRegisterBody(parsed) {
396
+ const slug = firstFlag(parsed, 'slug') ?? fail('--slug is required');
397
+ // Parsed before the source, so a malformed --dep fails as a sentence
398
+ // about the grammar before any request — the upload included — is made.
399
+ const dependencies = dependenciesFromFlags(parsed);
400
+ const source = await sourceFromFlags(parsed);
401
+ const account = firstFlag(parsed, 'account');
402
+ const body = {
403
+ // Named only when --account scopes the create elsewhere. Absent, the
404
+ // create handler lands the row in the CALLER's own account (or its
405
+ // `?account=` scope); a slug defaulted here was a guess about what the
406
+ // deployment's operator account is called, and a wrong guess minted an
407
+ // account of that name (beta review 2026-09-10, I2).
408
+ ...(account !== undefined ? { account } : {}),
409
+ slug,
410
+ displayName: firstFlag(parsed, 'name'),
411
+ source,
412
+ env: buildEnvMap(parsed.flags['env'] ?? [], parsed.flags['secret-env'] ?? []),
413
+ enabledTools: parseToolList(firstFlag(parsed, 'tools')),
414
+ // --follow opts into the daily auto-update cron moving the pin to the
415
+ // latest published version; omitted, the server default stays 'pinned'.
416
+ ...(parsed.booleans.has('follow') ? { updatePolicy: 'follow' } : {}),
417
+ // --data-dir gives this MCP a persistent directory on the runner volume
418
+ // as its HOME (and MCP_DATA_DIR). Omitted, the SERVER decides, and since
419
+ // docs/GLOBAL-REGISTRATIONS.md §5 it decides ON: an MCP that keeps a
420
+ // device id or a session under `~/` was losing it on every scale-to-zero,
421
+ // and that reads as a broken MCP rather than as a setting nobody set.
422
+ // --no-data-dir is how somebody says no; the flag pair is sent as an
423
+ // explicit boolean, and absent stays absent so a scripted create never
424
+ // pins whatever the default was on the day it was written.
425
+ ...dataDirFromFlags(parsed),
426
+ // --per-user-child gives every caller their own child process, which is
427
+ // what lets the MCP hold THEIR credential rather than its owner's.
428
+ //
429
+ // Still a flag, but no longer the only way to get one: since
430
+ // docs/GLOBAL-REGISTRATIONS.md §5 the server DERIVES it from
431
+ // `authFields`/`authFlow`, which have always forced it anyway — so
432
+ // `--auth-flow` alone is enough, and this flag is for an MCP that reads a
433
+ // per-caller credential the host never sees the shape of.
434
+ ...(parsed.booleans.has('per-user-child') ? { perUserChild: true } : {}),
435
+ // --bridge routes this MCP's fetches through the owner's browser rather
436
+ // than out of the machine (docs/BROWSER-BRIDGE.md). A flag rather than
437
+ // something inferred from the package, for the reason the mint reader
438
+ // refuses it too: a registration is bridged because its OWNER said so.
439
+ // Worth pairing with --data-dir, though not for the reason this note
440
+ // used to give: the identity is host-provisioned outside the child's HOME
441
+ // since #655, so a cold start no longer changes the pair code. What a
442
+ // bridged child still loses without persistence is its other state — a
443
+ // captured token, a session cache — on every scale-to-zero.
444
+ ...(parsed.booleans.has('bridge') ? { bridge: true } : {}),
445
+ // --dep: tools the MCP shells out to that are not in its npm tree
446
+ // (docs/REGISTERING.md §Dependencies). Sent unresolved; the gateway pins.
447
+ ...(dependencies.length > 0 ? { dependencies } : {}),
448
+ };
449
+ // --auth-flow ./flow.json: the multi-step login (core/src/auth-flow.ts).
450
+ // Read locally, before anything is sent, so a typo'd path fails as a
451
+ // sentence about a FILE rather than as a 400 about a registration.
452
+ const authFlowPath = firstFlag(parsed, 'auth-flow');
453
+ if (authFlowPath !== undefined) {
454
+ try {
455
+ body.authFlow = readJsonDocument(authFlowPath, '--auth-flow');
456
+ } catch (error) {
457
+ fail(error.message);
458
+ }
459
+ }
460
+ const bin = firstFlag(parsed, 'bin');
461
+ if (bin) body.command = { bin };
462
+ // --data: every create field no flag names (egress, authFields, runtime
463
+ // args, a marketplace listing, …). The flags are applied OVER it, but only
464
+ // flags that were PASSED: `env` and `enabledTools` are built even when
465
+ // their flags are absent ({} and null, which is what the body has always
466
+ // carried), and spreading those over --data silently dropped its env and
467
+ // its tool narrowing (auto-review #919). With no --data the body is
468
+ // unchanged, defaults included.
469
+ const data = await readDataFlag(firstFlag(parsed, 'data'));
470
+ const unpassed = new Set();
471
+ if (parsed.flags['env'] === undefined && parsed.flags['secret-env'] === undefined) unpassed.add('env');
472
+ if (firstFlag(parsed, 'tools') === undefined) unpassed.add('enabledTools');
473
+ const said = Object.fromEntries(
474
+ Object.entries(body).filter(([key, value]) => value !== undefined && !(unpassed.has(key) && key in data)),
475
+ );
476
+ return { ...data, ...said };
477
+ }
478
+
479
+ /**
480
+ * `--data <json | @file | ->`: a JSON object for the body fields no flag
481
+ * names. Flags are applied OVER it, so `--data @base.json --limit 5` is the
482
+ * file with one field changed.
483
+ */
484
+ async function readDataFlag(value) {
485
+ if (value === undefined) return {};
486
+ let text;
487
+ if (value === '-') text = await readStdin();
488
+ else if (value.startsWith('@')) {
489
+ try {
490
+ return readJsonDocument(value.slice(1), '--data');
491
+ } catch (error) {
492
+ return fail(error.message);
493
+ }
494
+ } else text = value;
495
+ let parsed;
496
+ try {
497
+ parsed = JSON.parse(text);
498
+ } catch (error) {
499
+ return fail(`--data is not valid JSON: ${error.message}`);
500
+ }
501
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) fail('--data must be a JSON object');
502
+ return parsed;
503
+ }
504
+
505
+ /**
506
+ * `?a=…&b=…` from the `--kebab` flags for `names`, plus `name=1` for each
507
+ * bare flag in `booleans`; empty when none was passed. The bespoke commands'
508
+ * half of what runGeneric does, so a row's declared `query` is one call.
509
+ */
510
+ function queryFor(parsed, names, booleans = []) {
511
+ const query = new URLSearchParams();
512
+ for (const name of names) {
513
+ const value = firstFlag(parsed, flagName(name));
514
+ if (value !== undefined) query.set(name, value);
515
+ }
516
+ for (const name of booleans) if (parsed.booleans.has(flagName(name))) query.set(name, '1');
517
+ return query.size > 0 ? `?${query}` : '';
518
+ }
519
+
520
+ /** The list-query names (core/src/list-query.ts), optionally under a prefix. */
521
+ const LIST_QUERY = ['sort', 'order', 'q', 'page', 'perPage'];
522
+ const listQuery = (prefix = '') => LIST_QUERY.map((n) => (prefix ? `${prefix}${n[0].toUpperCase()}${n.slice(1)}` : n));
523
+
524
+ /** One typed body flag's value, or a failure naming the flag. */
525
+ function coerceField(flag, type, raw) {
526
+ if (type === 'string') return raw;
527
+ if (type === 'integer' || type === 'number') {
528
+ const n = Number(raw);
529
+ if (raw.trim() === '' || !Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) fail(`--${flag} takes ${type === 'integer' ? 'an integer' : 'a number'}, not ${raw}`);
530
+ return n;
531
+ }
532
+ try {
533
+ return JSON.parse(raw);
534
+ } catch (error) {
535
+ return fail(`--${flag} takes JSON: ${error.message}`);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Execute a GENERIC row of operations.mjs: the whole of what the CLI does for
541
+ * an operation that is one request and a printed answer. Positionals named in
542
+ * the path fill it (encoded); the rest are body fields; each declared query
543
+ * name is a `--kebab` flag, and so is each declared body field. A flag the row
544
+ * does not declare is REFUSED rather than ignored — `parseArgs` would bind the
545
+ * next token to it, and a typo'd `--linez 5` quietly tailing the default is
546
+ * exactly the silent option this table exists to stop.
547
+ */
548
+ async function runGeneric(row, argv) {
549
+ const body = row.body ?? {};
550
+ const booleans = new Set(Object.entries(body).filter(([, t]) => t === 'boolean').map(([f]) => flagName(f)));
551
+ let parsed;
552
+ try {
553
+ parsed = parseArgs(argv, booleans);
554
+ } catch (error) {
555
+ return fail(error.message);
556
+ }
557
+ const usage = `usage: mcp-host ${usageFor(row)}`;
558
+ const known = new Set([...(row.query ?? []).map(flagName), ...Object.keys(body).map(flagName), ...(row.body ? ['data'] : [])]);
559
+ for (const name of [...Object.keys(parsed.flags), ...parsed.booleans]) {
560
+ if (!known.has(name)) fail(`unknown flag --${name}\n${usage}`);
561
+ }
562
+ const args = row.args ?? [];
563
+ if (parsed.positionals.length !== args.length) fail(usage);
564
+
565
+ const [method, template] = row.op.split(' ');
566
+ const params = pathParams(row.op);
567
+ let path = template;
568
+ params.forEach((name, i) => {
569
+ path = path.replace(`{${name}}`, encodeURIComponent(parsed.positionals[i]));
570
+ });
571
+ const query = new URLSearchParams();
572
+ for (const name of row.query ?? []) {
573
+ const value = firstFlag(parsed, flagName(name));
574
+ if (value !== undefined) query.set(name, value);
575
+ }
576
+ if (query.size > 0) path += `?${query}`;
577
+
578
+ if (!row.body) return print(await api(method, path));
579
+ const payload = await readDataFlag(firstFlag(parsed, 'data'));
580
+ args.slice(params.length).forEach((field, i) => {
581
+ payload[field] = parsed.positionals[params.length + i];
582
+ });
583
+ for (const [field, type] of Object.entries(body)) {
584
+ const flag = flagName(field);
585
+ if (type === 'boolean') {
586
+ if (parsed.booleans.has(flag)) payload[field] = true;
587
+ continue;
588
+ }
589
+ const raw = firstFlag(parsed, flag);
590
+ if (raw !== undefined) payload[field] = coerceField(flag, type, raw);
591
+ }
592
+ return print(await api(method, path, payload));
593
+ }
594
+
595
+ /**
596
+ * `logs <id> [--lines N]`: the `/logs` route, printed as the bare array of
597
+ * lines this command has always printed (it read `status.instance.lastStderr`,
598
+ * a string[], before the route existed). Scripts parse that shape, so the
599
+ * route moved and the output did not; the timestamps are what `status` and the
600
+ * portal are for.
601
+ */
602
+ async function logs(argv) {
603
+ const parsed = parseArgs(argv);
604
+ for (const name of [...Object.keys(parsed.flags), ...parsed.booleans]) {
605
+ if (name !== 'lines') fail(`unknown flag --${name}\nusage: mcp-host logs <id> [--lines N]`);
606
+ }
607
+ const id = need(parsed.positionals[0], 'id');
608
+ const result = await api('GET', `/api/v1/registrations/${encodeURIComponent(id)}/logs${queryFor(parsed, ['lines'])}`);
609
+ return print((result.logs ?? []).map((entry) => entry.line));
610
+ }
611
+
612
+ /** Refuse any flag outside `known`, naming the usage line. */
613
+ function refuseUnknownFlags(parsed, known, usage) {
614
+ for (const name of [...Object.keys(parsed.flags), ...parsed.booleans]) {
615
+ if (!known.includes(name)) fail(`unknown flag --${name}\n${usage}`);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * `approvals [ls]`: the tool calls a policy said to ASK about (spec 2026-10-19
621
+ * D3). The JSON on stdout is the listing as the API answered it; stderr gets
622
+ * one line per row — MCP, tool, argument preview, time left — because what a
623
+ * person does with this list is decide, and a JSON blob is not where the
624
+ * expiry of the request they are about to approve should have to be found.
625
+ */
626
+ async function approvals(argv) {
627
+ const usage = 'usage: mcp-host approvals [ls] [--status pending|approved|refused|consumed|expired|all] [--account slug] [--sort v] [--order v] [--q v] [--page v] [--per-page v]';
628
+ let parsed;
629
+ try {
630
+ parsed = parseArgs(argv);
631
+ } catch (error) {
632
+ return fail(error.message);
633
+ }
634
+ const names = ['status', 'account', ...listQuery()];
635
+ refuseUnknownFlags(parsed, names.map(flagName), usage);
636
+ const [sub, ...extra] = parsed.positionals;
637
+ if ((sub !== undefined && sub !== 'ls') || extra.length > 0) fail(usage);
638
+ const listed = await api('GET', `/api/v1/approvals${queryFor(parsed, names)}`);
639
+ print(listed);
640
+ const rows = listed.approvals ?? [];
641
+ if (rows.length === 0) {
642
+ console.error(`mcp-host: nothing waiting for approval${firstFlag(parsed, 'status') ? ` (status ${firstFlag(parsed, 'status')})` : ''}`);
643
+ return;
644
+ }
645
+ for (const line of approvalLines(rows)) console.error(line);
646
+ if (rows.some((row) => row.status === 'pending')) {
647
+ console.error(' mcp-host approvals approve <id> | mcp-host approvals refuse <id>');
648
+ }
649
+ }
650
+
651
+ /**
652
+ * `me set --push-tenant-alerts on|off`: the owner's own switch for pushes
653
+ * about their MCPs (spec 2026-10-19 D4). Every other `me …` is a generic row
654
+ * and never reaches here. `--data` is kept because the generic row this
655
+ * replaced took a body (#1171); the flag wins over it, as in profileWrite.
656
+ */
657
+ async function meSet(argv) {
658
+ const usage = 'usage: mcp-host me set --push-tenant-alerts on|off [--data <json>]';
659
+ const [sub, ...more] = argv;
660
+ if (sub !== 'set') {
661
+ console.error(helpText());
662
+ return process.exit(1);
663
+ }
664
+ let parsed;
665
+ try {
666
+ parsed = parseArgs(more);
667
+ } catch (error) {
668
+ return fail(error.message);
669
+ }
670
+ refuseUnknownFlags(parsed, ['push-tenant-alerts', 'data'], usage);
671
+ if (parsed.positionals.length > 0) fail(usage);
672
+ const body = await readDataFlag(firstFlag(parsed, 'data'));
673
+ const raw = firstFlag(parsed, 'push-tenant-alerts');
674
+ if (raw !== undefined) {
675
+ try {
676
+ body.pushTenantAlerts = parseOnOff(raw, '--push-tenant-alerts');
677
+ } catch (error) {
678
+ return fail(error.message);
679
+ }
680
+ }
681
+ if (Object.keys(body).length === 0) fail(`nothing to set: pass --push-tenant-alerts on|off\n${usage}`);
682
+ return print(await api('PUT', '/api/v1/me', body));
683
+ }
684
+
685
+ /**
686
+ * `profile add|set`: a profile's overlay from repeated `--secret-env
687
+ * VAR=SECRET` (the register spelling), or `--env <json>` for a script, plus
688
+ * `--label`. Naming both overlay forms is a mistake, not a precedence
689
+ * question. `--data` carries anything no flag names; the flags win over it.
690
+ * `profile ls|rm` are generic rows and never reach here.
691
+ */
692
+ async function profileWrite(argv) {
693
+ const [sub, ...more] = argv;
694
+ if (sub !== 'add' && sub !== 'set') {
695
+ console.error(helpText());
696
+ return process.exit(1);
697
+ }
698
+ const usage =
699
+ sub === 'add'
700
+ ? 'usage: mcp-host profile add <id> --label <label> (--secret-env VAR=SECRET … | --env <json>) [--account slug] [--data <json>]'
701
+ : 'usage: mcp-host profile set <id> <profileId> [--label <label>] [--secret-env VAR=SECRET … | --env <json>] [--account slug] [--data <json>]';
702
+ let parsed;
703
+ try {
704
+ parsed = parseArgs(more);
705
+ } catch (error) {
706
+ return fail(error.message);
707
+ }
708
+ refuseUnknownFlags(parsed, ['label', 'secret-env', 'env', 'account', 'data'], usage);
709
+ const ids = parsed.positionals;
710
+ if (ids.length !== (sub === 'add' ? 1 : 2)) fail(usage);
711
+ const secretEnv = parsed.flags['secret-env'];
712
+ const envJson = firstFlag(parsed, 'env');
713
+ if (secretEnv !== undefined && envJson !== undefined) fail('pass --secret-env or --env, not both');
714
+ const body = await readDataFlag(firstFlag(parsed, 'data'));
715
+ const label = firstFlag(parsed, 'label');
716
+ if (label !== undefined) body.label = label;
717
+ if (secretEnv !== undefined) {
718
+ try {
719
+ body.env = buildEnvMap([], secretEnv);
720
+ } catch (error) {
721
+ return fail(error.message);
722
+ }
723
+ } else if (envJson !== undefined) {
724
+ body.env = coerceField('env', 'json', envJson);
725
+ }
726
+ if (sub === 'add') {
727
+ if (body.label === undefined) fail(`--label is required\n${usage}`);
728
+ if (body.env === undefined) fail(`pass --secret-env VAR=SECRET (repeatable) or --env <json>\n${usage}`);
729
+ } else if (Object.keys(body).length === 0) {
730
+ fail(`nothing to set: pass --label, --secret-env or --env\n${usage}`);
731
+ }
732
+ const [id, profileId] = ids.map((part) => encodeURIComponent(part));
733
+ const path = `/api/v1/registrations/${id}/profiles${sub === 'set' ? `/${profileId}` : ''}${queryFor(parsed, ['account'])}`;
734
+ return print(await api(sub === 'add' ? 'POST' : 'PUT', path, body));
735
+ }
736
+
737
+ function print(value) {
738
+ console.log(JSON.stringify(value, null, 2));
739
+ }
740
+
741
+ async function main() {
742
+ const [command, ...rest] = ARGV;
743
+
744
+ if (command === 'help' || HELP) {
745
+ console.log(helpFor(ARGV.filter((arg) => arg !== '--help' && arg !== '-h' && arg !== 'help')));
746
+ return;
747
+ }
748
+ // A one-request operation is a row of operations.mjs, executed by data.
749
+ const row = matchGeneric(ARGV);
750
+ if (row) return runGeneric(row, ARGV.slice(row.words.length));
751
+
752
+ switch (command) {
753
+ case 'wizard':
754
+ return wizard();
755
+ case 'builder-key': {
756
+ // LEGACY (audit 2026-09-01, finding 3). This prints HMAC(master,
757
+ // 'builder') — which is exactly the problem: every POOL machine holds
758
+ // the master, so every pool machine can mint the one identity allowed to
759
+ // publish an artifact the whole fleet executes. The builder's key is now
760
+ // BUILDER_KEY, an independent secret (`openssl rand -hex 32`,
761
+ // docs/DEPLOY.md §6), and the gateway REFUSES this derived value as soon
762
+ // as BUILDER_KEY is set.
763
+ //
764
+ // Kept because a deployment mid-migration still runs on it, and because
765
+ // an operator debugging a 401 needs to see the value the old builder is
766
+ // presenting. The note goes to STDERR: stdout stays the bare key so
767
+ // `$(mcp-host builder-key)` is unchanged for anyone still on it.
768
+ const master = process.env.RUNNER_KEY;
769
+ if (!master) fail('set RUNNER_KEY (the gateway\'s master runner key) in the environment');
770
+ console.error(
771
+ 'warning: builder-key prints the LEGACY derived builder key. Every pool machine can mint it. ' +
772
+ 'The builder now holds BUILDER_KEY, an independent secret — see docs/DEPLOY.md §6.',
773
+ );
774
+ console.log(deriveMachineKey(master, 'builder'));
775
+ return;
776
+ }
777
+ case 'register': {
778
+ const parsed = parseArgs(rest, new Set(['follow', 'data-dir', 'no-data-dir', 'per-user-child', 'bridge']));
779
+ // No argv at all, and a person at a terminal: the wizard. Only then —
780
+ // any flag at all means the flags path (`--account x` with no slug is
781
+ // a mistyped command, not a request for questions that would discard
782
+ // the flag), whose errors (a missing --slug first) a script may be
783
+ // matching on, and a pipe keeps those errors byte-identical.
784
+ if (wantsWizard(rest, process.stdin.isTTY)) return wizard();
785
+ const body = await buildRegisterBody(parsed);
786
+ const created = await api('POST', '/api/v1/registrations', body);
787
+ print(created);
788
+ console.error('\nAdd to claude.ai: Settings -> Connectors -> Add custom connector ->');
789
+ // The short, account-free URL where the gateway offers one — it is
790
+ // resolved in the calling account, so it is the one to paste — falling
791
+ // back to the account-qualified URL an older gateway reports. Both work.
792
+ console.error(` URL: ${created.shortConnectUrl ?? created.connectUrl}`);
793
+ console.error(' Sign in there and approve the connection — no key to paste.');
794
+ return;
795
+ }
796
+ case 'build': {
797
+ // Already built answers from the store; --force asks the builder anyway
798
+ // (operator-only — the gateway answers a 403 to anyone else, since the
799
+ // flag skips the short-circuit onto the fleet's one serial builder).
800
+ // The ask is QUEUED (202, "building") unless --wait, which holds on for
801
+ // the manifest — a github build with devDependencies can take minutes,
802
+ // so waiting is the option: `mcp-host status <id>` shows the outcome.
803
+ const parsed = parseArgs(rest, new Set(['force', 'wait']));
804
+ const id = need(parsed.positionals[0], 'id');
805
+ const result = await api('POST', `/api/v1/registrations/${encodeURIComponent(id)}/build${queryFor(parsed, [], ['force', 'wait'])}`);
806
+ if (result.accepted === true) {
807
+ console.error(`mcp-host: build queued; \`mcp-host status ${id}\` shows artifact: ready|failed when it lands (or rerun with --wait)`);
808
+ }
809
+ return print(result);
810
+ }
811
+ case 'set': {
812
+ // Edit ONE registration in place. The control API's PUT is a partial
813
+ // patch — an omitted field is left alone — so this sends only what was
814
+ // asked for and is the extension point for the rest of the patch.
815
+ //
816
+ // `--data-dir` exists here and not only on `register` because the flag
817
+ // is one an owner reaches for AFTER the fact: an MCP that turns out to
818
+ // keep a cache or a session under `~/` is losing it on every idle stop
819
+ // (the runner says so on `status`), and the fix must not require
820
+ // hand-rolling a PUT.
821
+ const parsed = parseArgs(
822
+ rest,
823
+ new Set(['data-dir', 'no-data-dir', 'no-deps', 'follow', 'no-follow', 'allow-all', 'no-ask', 'all-tools']),
824
+ );
825
+ // From the positionals, never rest[0]: with the flag written first that
826
+ // would take `--data-dir` for the id.
827
+ const id = need(parsed.positionals[0], 'id');
828
+ // Every argv refusal BEFORE any request: the policy and allowlist flags
829
+ // below read the registration first, and a malformed flag must not cost
830
+ // a round trip (or, for --tools, a machine spawn) to be refused.
831
+ let policyChange;
832
+ let tools;
833
+ try {
834
+ policyChange = toolPolicyChange(parsed);
835
+ tools = parseToolList(firstFlag(parsed, 'tools'));
836
+ } catch (error) {
837
+ fail(error.message);
838
+ }
839
+ const allTools = parsed.booleans.has('all-tools');
840
+ if (allTools && tools !== null) fail('pass --tools or --all-tools, not both');
841
+ if (!AGGREGATE_MODES.includes(firstFlag(parsed, 'aggregate') ?? 'search')) {
842
+ fail('--aggregate takes inline, search or exclude');
843
+ }
844
+ const on = parsed.booleans.has('data-dir');
845
+ const off = parsed.booleans.has('no-data-dir');
846
+ if (on && off) fail('pass one of --data-dir or --no-data-dir, not both');
847
+ // --data: any PUT field no flag names (egress, env, enabledTools,
848
+ // authFields, status, …); the flags below are applied over it.
849
+ const patch = await readDataFlag(firstFlag(parsed, 'data'));
850
+ if (on || off) patch.dataDir = on;
851
+ // --state-store volume|snapshot: WHERE that directory lives (#521).
852
+ //
853
+ // `volume` is the machine-local one `dataDir` has always meant on
854
+ // fly-shared, and it is what every row reads until somebody changes it.
855
+ // `snapshot` moves the registration onto the sealed R2 archive the
856
+ // retired isolated tier used, so losing a pool machine stops losing its
857
+ // state —
858
+ // which is what a lost machine did to honeybook's fetchproxy identity,
859
+ // and therefore to its browser pairing.
860
+ //
861
+ // Moving OFF `snapshot` RETIRES the archive in the same write, because a
862
+ // snapshot nothing is writing goes stale and restoring it later would be
863
+ // a resurrection rather than a restore. The server does that; it is said
864
+ // here so the flag does not read as reversible bookkeeping.
865
+ const stateStore = firstFlag(parsed, 'state-store');
866
+ if (stateStore !== undefined) {
867
+ if (stateStore !== 'volume' && stateStore !== 'snapshot') {
868
+ fail('--state-store takes volume or snapshot');
869
+ }
870
+ patch.stateStore = stateStore;
871
+ }
872
+ // --dep REPLACES the list (the PUT takes the whole array, and a merge
873
+ // would leave no way to drop one), and --no-deps clears it. Naming both
874
+ // is a mistake rather than a precedence question.
875
+ const dependencies = dependenciesFromFlags(parsed);
876
+ const noDeps = parsed.booleans.has('no-deps');
877
+ if (dependencies.length > 0 && noDeps) fail('pass --dep or --no-deps, not both');
878
+ if (dependencies.length > 0) patch.dependencies = dependencies;
879
+ else if (noDeps) patch.dependencies = [];
880
+ // --follow / --no-follow: hand the daily auto-update cron permission to
881
+ // move this row's pin, or take it back. It exists on `set` and not only
882
+ // on `register` for the reason --data-dir does: it is a decision an owner
883
+ // makes AFTER the fact — a row that has fallen behind its siblings, or
884
+ // one that must hold a pin through a bad upstream release — and the fix
885
+ // should not require hand-rolling a PUT.
886
+ //
887
+ // `follow` does NOT loosen the pin: the row still names one exact
888
+ // version, and the cron re-resolves through the same resolver and writes
889
+ // through the same path a source PUT takes. It only decides who may move
890
+ // it. An `mcpb` source has no channel to follow and the server refuses
891
+ // it there; that refusal is left to the server, which is the only side
892
+ // that knows the source kind of a row this command has not fetched.
893
+ const follow = parsed.booleans.has('follow');
894
+ const noFollow = parsed.booleans.has('no-follow');
895
+ if (follow && noFollow) fail('pass one of --follow or --no-follow, not both');
896
+ if (follow || noFollow) patch.updatePolicy = follow ? 'follow' : 'pinned';
897
+ // --bridge-approval account|prompt (plan task B4, spec §4.5): how a
898
+ // browser attach is consented to. `prompt` makes every account
899
+ // attestation for this registration confirm-each; `account` lets an
900
+ // attach be silent once an owner has approved its scope. Setting it back
901
+ // to `account` as anyone but the scope's approver moves that approval
902
+ // back to a proposal — the server does that, and says so on
903
+ // `bridge-scope`.
904
+ const bridgeApproval = firstFlag(parsed, 'bridge-approval');
905
+ if (bridgeApproval !== undefined) {
906
+ if (bridgeApproval !== 'account' && bridgeApproval !== 'prompt') {
907
+ fail('--bridge-approval takes account or prompt');
908
+ }
909
+ patch.bridgeApproval = bridgeApproval;
910
+ }
911
+ // --deny destructive|non-read-only / --allow-all / --ask a,b / --no-ask:
912
+ // the tool POLICY (spec D1). Bookkeeping, not configuration — judged at
913
+ // the gateway, so saving it moves no configHash and restarts nothing.
914
+ // The PUT replaces it WHOLE, so a flag naming one half (deny classes, or
915
+ // the ask list) keeps the other: from `--data`'s toolPolicy when that
916
+ // names one, else read off the registration first. A change naming both
917
+ // halves has nothing stored to keep and reads nothing.
918
+ // --aggregate inline|search|exclude (plan task G4, spec D8): how this
919
+ // registration takes part in the account's aggregate connector
920
+ // (`mcp-host connector`). Bookkeeping like the policy — saving it moves
921
+ // no configHash and restarts nothing — and a whole value, so unlike the
922
+ // policy it has nothing stored to keep and reads nothing first.
923
+ const aggregate = firstFlag(parsed, 'aggregate');
924
+ if (aggregate !== undefined) patch.aggregate = aggregate;
925
+ if (policyChange !== null) {
926
+ const both = policyChange.deny !== undefined && policyChange.ask !== undefined;
927
+ const base =
928
+ patch.toolPolicy ??
929
+ (both ? undefined : (await api('GET', `/api/v1/registrations/${encodeURIComponent(id)}`)).toolPolicy);
930
+ patch.toolPolicy = mergeToolPolicy(base, policyChange);
931
+ }
932
+ // --tools a,b: narrow the allowlist (the gap this flag closes: it
933
+ // existed on `register` only). A save of the allowlist ACKNOWLEDGES the
934
+ // new-tools review for `reviewedTools ∪ enabledTools` — and with no
935
+ // `reviewedTools` at all, for every name the gateway holds, including
936
+ // ones this terminal never showed. So the menu is read and PRINTED first
937
+ // (the `tools <id>` read, which may start an idle child, as that command
938
+ // does), and exactly those names are sent. When the menu cannot be read
939
+ // (a disabled registration refuses it), only the named tools are
940
+ // acknowledged, and that is said.
941
+ if (tools !== null) {
942
+ let reviewed = tools;
943
+ try {
944
+ const menu = await apiRequest('GET', `/api/v1/registrations/${encodeURIComponent(id)}/tools`);
945
+ reviewed = (menu.tools ?? []).map((tool) => tool.name).filter((name) => typeof name === 'string');
946
+ console.error(`mcp-host: the tool menu (acknowledged as reviewed): ${reviewed.join(', ') || '(empty)'}`);
947
+ } catch (error) {
948
+ if (!(error instanceof ApiError)) throw error;
949
+ console.error(`mcp-host: the tool menu could not be read (${error.message}); acknowledging only the named tools`);
950
+ }
951
+ patch.enabledTools = tools;
952
+ patch.reviewedTools = reviewed;
953
+ }
954
+ // --all-tools: back to allow-all (`enabledTools: null`), the inverse
955
+ // --tools lacked. Nothing to acknowledge: allow-all serves every tool the
956
+ // child publishes, so there is no new-tools review left to answer, and
957
+ // nothing is read first.
958
+ if (allTools) patch.enabledTools = null;
959
+ // Toggling dataDir or the dependencies moves configHash and so restarts
960
+ // the child; a PUT that patches nothing would spend that restart for no change.
961
+ if (Object.keys(patch).length === 0)
962
+ fail(
963
+ 'nothing to set: pass --data-dir, --no-data-dir, --state-store, --follow, --no-follow, --bridge-approval, --dep, --no-deps, ' +
964
+ '--deny, --allow-all, --ask, --no-ask, --tools, --all-tools, --aggregate or --data <json>',
965
+ );
966
+ return print(await api('PUT', `/api/v1/registrations/${encodeURIComponent(id)}`, patch));
967
+ }
968
+ case 'preview': {
969
+ // POST /api/v1/registrations/preview: everything a create WOULD resolve
970
+ // — the exact pin, each dependency's pin, the package's bins, proposed
971
+ // env — and it writes nothing (docs/REGISTERING.md §Preview). The same
972
+ // source and --dep flags as `register`, read by the same code, so what
973
+ // the preview resolved is what the register will send.
974
+ const parsed = parseArgs(rest);
975
+ const dependencies = dependenciesFromFlags(parsed);
976
+ const source = await sourceFromFlags(parsed);
977
+ return print(
978
+ await api('POST', '/api/v1/registrations/preview', {
979
+ source,
980
+ ...(dependencies.length > 0 ? { dependencies } : {}),
981
+ }),
982
+ );
983
+ }
984
+ case 'bundles': {
985
+ // `bundles upload <path>`: the upload on its own. What it prints is
986
+ // the sha256 — the pin — so a bundle uploaded once can be registered
987
+ // later (`--mcpb sha256:<hex>`) or by several registrations, and THIS
988
+ // ACCOUNT uploading the same bytes twice answers `existed: true` with
989
+ // the same row. Scoped to the caller, never to the deployment: another
990
+ // account's identical bytes answer `existed: false`, because a
991
+ // fleet-wide flag is a hash oracle (2026-09-01 audit, finding 16).
992
+ const parsed = parseArgs(rest);
993
+ const [sub, file] = parsed.positionals;
994
+ if (sub === 'upload') {
995
+ const view = await uploadBundleOrFail(need(file, 'bundle path'));
996
+ print(view);
997
+ console.error(`\nRegister it: mcp-host register --slug <slug> --mcpb sha256:${view.sha256}`);
998
+ return;
999
+ }
1000
+ return fail('bundles subcommands: upload <path.mcpb>');
1001
+ }
1002
+ case 'infra': {
1003
+ // The super-admin infrastructure view: machines per Fly app, the shared
1004
+ // pool's machine set, then the windowed per-registration resource spend
1005
+ // and the deployment total. Parse FIRST and read the subcommand from the
1006
+ // positionals, for the reason `marketplace` does: `infra --days 7` must
1007
+ // stay the view, not bind `7` as a verb.
1008
+ const parsed = parseArgs(rest);
1009
+ const [sub, machineId] = parsed.positionals;
1010
+ if (sub === 'sync') {
1011
+ // The pool's set is what the gateway PLACES over, so a machine that
1012
+ // `fly scale count` added serves nothing until it is synced in, and
1013
+ // one destroyed without a drain keeps being placed until then (the
1014
+ // first request to it heals that; the cron does it daily). Refused
1015
+ // machines are listed with the reason — a machine with no volume is
1016
+ // the usual one (docs/DEPLOY.md).
1017
+ const outcome = await api('POST', '/api/v1/infra/shared-machines/sync');
1018
+ print(outcome);
1019
+ for (const r of outcome.refused) console.error(`refused ${r.machineId} (${r.name ?? '-'}): ${r.reason}`);
1020
+ return;
1021
+ }
1022
+ if (sub === 'drain' || sub === 'undrain') {
1023
+ if (!machineId) return fail(`usage: mcp-host infra ${sub} <machine id>`);
1024
+ const out = await api('POST', `/api/v1/infra/shared-machines/${encodeURIComponent(machineId)}/${sub}`);
1025
+ print(out);
1026
+ if (sub === 'drain') {
1027
+ console.error('\nIts registrations fall to their runner-up on their next request; nothing is moved for them.');
1028
+ console.error('A dataDir registration leaves its /data/state behind on this machine (docs/DEPLOY.md).');
1029
+ }
1030
+ return;
1031
+ }
1032
+ if (sub !== undefined) return fail('infra subcommands: (none = view) | sync | drain <machine id> | undrain <machine id>');
1033
+ const data = await api('GET', `/api/v1/infra${queryFor(parsed, ['account', 'days', ...listQuery('res')])}`);
1034
+ // One section. There was a second, the isolated app's, and it went with
1035
+ // that tier (2026-09-11); the loop stays a loop because the shape here is
1036
+ // "one block per app" and the next app added is rendered the same way.
1037
+ for (const [label, section] of [['shared runner', data.machines.shared]]) {
1038
+ console.log(`# ${label} (${section.app ?? 'unconfigured'})`);
1039
+ if (!section.available) {
1040
+ console.log(` unavailable: ${section.reason ?? 'unknown'}`);
1041
+ continue;
1042
+ }
1043
+ if (section.machines.length === 0) console.log(' no machines');
1044
+ for (const m of section.machines) {
1045
+ console.log(
1046
+ ` ${m.id} ${m.state ?? '?'} ${m.name ?? '-'} ${m.region ?? '-'} ` +
1047
+ `${m.cpus ?? '?'}cpu/${m.memoryMb ?? '?'}MB ${m.image ?? '-'}`,
1048
+ );
1049
+ }
1050
+ }
1051
+ // The pool: every row with its state, and the share each LIVE machine
1052
+ // would receive if every enabled fly-shared registration asked now —
1053
+ // a prediction from placement, not a ledger, which is why the column
1054
+ // is called that. An empty set is the pre-pool behaviour (no pin).
1055
+ const pool = data.sharedPool;
1056
+ if (pool) {
1057
+ console.log(`# shared pool (${pool.app ?? 'unconfigured'}): ${pool.placing} registrations placed`);
1058
+ if (pool.machines.length === 0) console.log(' empty set: no pin sent, Fly picks (run `mcp-host infra sync` to join machines)');
1059
+ for (const m of pool.machines) {
1060
+ console.log(
1061
+ ` ${m.machineId} ${m.state} ${m.name ?? '-'} ${m.region ?? '-'} ` +
1062
+ `${m.registrations} would place here seen ${m.lastSeenAt ?? '-'}`,
1063
+ );
1064
+ }
1065
+ }
1066
+ console.log(`# resources (last ${data.window.days}d)`);
1067
+ for (const line of data.resources) {
1068
+ console.log(
1069
+ ` ${line.slug ?? line.registrationId} ${line.requests} req` +
1070
+ `${line.errors ? ` (${line.errors} err)` : ''} up ${Math.round(line.upMs / 1000)}s ` +
1071
+ `cpu ${Math.round(line.cpuMs / 1000)}s out ${line.egressBytesOut}B ` +
1072
+ `est $${(line.estimatedCents / 100).toFixed(4)}`,
1073
+ );
1074
+ }
1075
+ const t = data.totals;
1076
+ console.log(
1077
+ `# total: ${t.requests} req, up ${Math.round(t.upMs / 1000)}s, cpu ${Math.round(t.cpuMs / 1000)}s, ` +
1078
+ `est $${(t.estimatedCents / 100).toFixed(4)}`,
1079
+ );
1080
+ return;
1081
+ }
1082
+ case 'secret': {
1083
+ // Parsed from the verb on, with the NAME taken from the positionals: a
1084
+ // fixed `rest.slice(2)` assumed every verb has a name, which `ls` does
1085
+ // not, so `secret ls --account x` would have dropped `--account`.
1086
+ const sub = rest[0];
1087
+ const parsed = parseArgs(rest.slice(1), new Set(['legacy']), new Set(['from-env']));
1088
+ const name = parsed.positionals[0];
1089
+ const account = firstFlag(parsed, 'account');
1090
+ if (sub === 'set' || sub === 'fingerprint') {
1091
+ const secretName = need(name, 'secret name');
1092
+ // Three sources, shared by `set` and `fingerprint` so the value is
1093
+ // read the same way whether it is being stored or checked. Naming two
1094
+ // at once is a mistake rather than a precedence question: silently
1095
+ // preferring one would store (or check) a value the caller did not
1096
+ // mean. The prompt and the env read leave the value out of the shell
1097
+ // history and out of `ps`; `--value` is for scripts that accept that.
1098
+ const value = await readSecretValue(parsed, secretName, sub === 'set' ? `Value for ${secretName}` : `Value to compare against ${secretName}`);
1099
+ if (sub === 'fingerprint') {
1100
+ // Compared HERE. The one request is the listing, which carries the
1101
+ // salt and the stored fingerprint; the value is hashed locally and
1102
+ // never sent — there is no server route that answers "is this the
1103
+ // value", because that route would be an oracle.
1104
+ let outcome;
1105
+ try {
1106
+ outcome = await compareFingerprint(apiRequest, secretName, value, { account });
1107
+ } catch (error) {
1108
+ return fail(error.message);
1109
+ }
1110
+ if (outcome.match) {
1111
+ console.log('match');
1112
+ return;
1113
+ }
1114
+ console.log(`NO MATCH (stored ${outcome.stored}, yours ${outcome.yours})`);
1115
+ process.exit(1);
1116
+ }
1117
+ // Sealed to the account's runner key before it leaves this process
1118
+ // (docs/SEALED-SECRETS.md): the gateway stores ciphertext it cannot
1119
+ // open. --legacy sends the old {value} body and is told so. The
1120
+ // THROWING api variant, so a 409 (the kid moved) can be retried once.
1121
+ try {
1122
+ const { stored } = await storeSecretSealed(apiRequest, secretName, value, { legacy: parsed.booleans.has('legacy'), account });
1123
+ return print(stored);
1124
+ } catch (error) {
1125
+ if (error instanceof ApiError) return fail(error.message);
1126
+ throw error;
1127
+ }
1128
+ }
1129
+ if (sub === 'ls') {
1130
+ const listed = await api('GET', `/api/v1/secrets${queryFor(parsed, ['account', ...listQuery()])}`);
1131
+ print(listed);
1132
+ // The fingerprint column, for a person: stdout stays the JSON a
1133
+ // script parses. A row without one is a legacy (gateway-readable)
1134
+ // row or one stored without; either way `secret set` re-stores it.
1135
+ for (const s of listed.secrets ?? []) {
1136
+ console.error(` ${s.name.padEnd(28)} ${s.fingerprint ?? '(no fingerprint — legacy row; re-set to seal it)'}${s.kid ? ` kid ${s.kid}` : ''}`);
1137
+ }
1138
+ return;
1139
+ }
1140
+ if (sub === 'rm') {
1141
+ return print(await api('DELETE', `/api/v1/secrets/${encodeURIComponent(need(name, 'secret name'))}${queryFor(parsed, ['account'])}`));
1142
+ }
1143
+ return fail(
1144
+ 'secret subcommands: set <NAME> [--value v | --from-env [VAR]] [--legacy] | fingerprint <NAME> [--value v | --from-env [VAR]] | ls | rm <NAME>',
1145
+ );
1146
+ }
1147
+ case 'connector': {
1148
+ // The account's ONE connect URL (plan task G4, spec D8): the aggregate
1149
+ // `/c/<account>/all/mcp`, which reaches every registration not set to
1150
+ // `--aggregate exclude`. The URL ALONE goes to stdout, so it can be
1151
+ // spliced into a command (`claude mcp add --transport http all "$(…)"`);
1152
+ // what it reaches goes to stderr for the person reading. The short
1153
+ // account-free form where the gateway offers it — it resolves in the
1154
+ // calling account, so it is the one to paste — else the qualified one.
1155
+ const parsed = parseArgs(rest, new Set(['json']));
1156
+ const account = firstFlag(parsed, 'account');
1157
+ const answer = await api('GET', `/api/v1/connector${account ? `?account=${encodeURIComponent(account)}` : ''}`);
1158
+ if (parsed.booleans.has('json')) return print(answer);
1159
+ const view = answer.connector;
1160
+ if (!view) return fail('this deployment has no account yet: register an MCP first, and the connector covers it');
1161
+ console.log(view.shortUrl ?? view.url);
1162
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
1163
+ console.error(`mcp-host: ${plural(view.registrations, 'MCP')} · ${plural(view.tools, 'tool')} through one connector`);
1164
+ if (view.needsConnection > 0) {
1165
+ console.error(` ${view.needsConnection} need you to connect them first (each signs in as you, on its own URL)`);
1166
+ }
1167
+ if (view.uncached > 0) console.error(` ${view.uncached} not used yet, so their tools are not listed until they are`);
1168
+ if (view.excluded > 0) console.error(` ${view.excluded} left out (\`mcp-host set <id> --aggregate search\` brings one in)`);
1169
+ console.error('Add it in claude.ai: Settings -> Connectors -> Add custom connector, and approve once.');
1170
+ return;
1171
+ }
1172
+ case 'marketplace': {
1173
+ // The personal plugin marketplace (docs/MARKETPLACE.md): the account's
1174
+ // enabled MCPs as a Claude Code marketplace behind a capability URL.
1175
+ // The URL is shown exactly once, by `rotate` — the token is stored
1176
+ // hashed, so a later `marketplace` can only report that a feed exists.
1177
+ // Parse FIRST, then read the subcommand from the positionals:
1178
+ // parseArgs consumes each flag's value, so `marketplace --account x`
1179
+ // yields no positional (status form) rather than binding `--account` —
1180
+ // or, as a hand-rolled scan here once did, `x` — as the subcommand.
1181
+ const parsed = parseArgs(rest);
1182
+ const [sub] = parsed.positionals;
1183
+ const account = firstFlag(parsed, 'account');
1184
+ const query = account ? `?account=${encodeURIComponent(account)}` : '';
1185
+ if (sub === 'rotate') {
1186
+ const minted = await api('POST', `/api/v1/marketplace/token${query}`);
1187
+ print(minted);
1188
+ console.error('\nSubscribe from Claude Code:');
1189
+ console.error(` ${minted.addCommand}`);
1190
+ console.error('Then: /plugin — every enabled MCP appears as an installable plugin.');
1191
+ console.error(minted.note);
1192
+ return;
1193
+ }
1194
+ if (sub === 'revoke') {
1195
+ return print(await api('DELETE', `/api/v1/marketplace/token${query}`));
1196
+ }
1197
+ if (sub === undefined) {
1198
+ const status = await api('GET', `/api/v1/marketplace${query}`);
1199
+ print(status);
1200
+ if (status.marketplace) {
1201
+ console.error('\nThe URL is shown only when minted. `mcp-host marketplace rotate` issues a fresh one');
1202
+ console.error('(the old URL stops working immediately).');
1203
+ } else {
1204
+ console.error('\nNo marketplace URL yet. Mint one with: mcp-host marketplace rotate');
1205
+ }
1206
+ return;
1207
+ }
1208
+ return fail('marketplace subcommands: (none = status) | rotate | revoke');
1209
+ }
1210
+ case 'bridge': {
1211
+ // Bridge credentials for the hosted browser bridge
1212
+ // (docs/BROWSER-BRIDGE.md): one per browser, account-scoped, and able to
1213
+ // do nothing but attach that browser. The CLI lists and revokes them; it
1214
+ // cannot mint one — since D18 a browser connects from its own extension
1215
+ // (Connect in the popup, one Confirm on the gateway page), and the old
1216
+ // mint answers 410.
1217
+ const parsed = parseArgs(rest);
1218
+ const [sub, ...words] = parsed.positionals;
1219
+ const account = firstFlag(parsed, 'account');
1220
+ const query = account ? `?account=${encodeURIComponent(account)}` : '';
1221
+ if (sub === 'new' || sub === 'pair') {
1222
+ return fail(
1223
+ 'bridge new/pair are retired (D18): click Connect in the ContextMint extension, sign in if asked, and confirm on the page it opens',
1224
+ );
1225
+ }
1226
+ if (sub === 'revoke') {
1227
+ return print(await api('DELETE', `/api/v1/bridge-tokens/${encodeURIComponent(need(words[0], 'credential id'))}${query}`));
1228
+ }
1229
+ if (sub === undefined || sub === 'ls') {
1230
+ return print(await api('GET', `/api/v1/bridge-tokens${queryFor(parsed, ['account', ...listQuery()])}`));
1231
+ }
1232
+ return fail('bridge subcommands: (none = ls) | ls | revoke <id>');
1233
+ }
1234
+ case 'logs':
1235
+ return logs(rest);
1236
+ case 'approvals':
1237
+ return approvals(rest);
1238
+ case 'me':
1239
+ return meSet(rest);
1240
+ case 'profile':
1241
+ return profileWrite(rest);
1242
+ case 'connections': {
1243
+ // Only the two hand-written connection commands reach here; every other
1244
+ // `connections …` is a generic row (operations.mjs).
1245
+ const [sub, ...more] = rest;
1246
+ if (sub === 'login') {
1247
+ const parsed = parseArgs(more);
1248
+ const id = need(parsed.positionals[0], 'principal id');
1249
+ const interactive = Boolean(process.stdin.isTTY);
1250
+ const prompter = interactive ? makePrompter() : undefined;
1251
+ try {
1252
+ const { result, exitCode } = await runLogin(api, id, { interactive, prompter });
1253
+ print(result);
1254
+ if (exitCode !== 0) process.exit(exitCode);
1255
+ } finally {
1256
+ prompter?.close();
1257
+ }
1258
+ return;
1259
+ }
1260
+ if (sub === 'secret' && more[0] === 'set') {
1261
+ // The portal's Connections repair, for a mistyped credential: the
1262
+ // alternative is revoke-and-reauthorize, which loses the grant and its
1263
+ // usage history. Sent as `{value}` — a principal secret is one of the
1264
+ // documented gateway-SEALED exceptions (docs/SEALED-SECRETS.md), as the
1265
+ // portal sends it — and read like `secret set`'s, off the command line
1266
+ // only when `--value` says so.
1267
+ const parsed = parseArgs(more.slice(1), new Set(), new Set(['from-env']));
1268
+ const [id, name] = parsed.positionals;
1269
+ need(id, 'principal id');
1270
+ need(name, 'credential name');
1271
+ const value = await readSecretValue(parsed, name, `Value for ${name}`);
1272
+ return print(await api('PUT', `/api/v1/principals/${encodeURIComponent(id)}/secrets/${encodeURIComponent(name)}`, { value }));
1273
+ }
1274
+ console.error(helpText());
1275
+ return process.exit(1);
1276
+ }
1277
+ default:
1278
+ console.error(helpText());
1279
+ process.exit(1);
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * The value for `secret set` / `secret fingerprint`: `--value`, `--from-env
1285
+ * [VAR]`, a no-echo prompt on a terminal, else stdin. Fails with the env check
1286
+ * BEFORE prompting, so a wizard-style question is never asked and then wasted.
1287
+ */
1288
+ async function readSecretValue(parsed, secretName, prompt) {
1289
+ const explicit = firstFlag(parsed, 'value');
1290
+ const wantsEnv = flagPresent(parsed, 'from-env');
1291
+ if (explicit !== undefined && wantsEnv) fail('pass one of --value or --from-env, not both');
1292
+ let value;
1293
+ if (wantsEnv) {
1294
+ let resolved;
1295
+ try {
1296
+ resolved = readSecretEnv(secretName, firstFlag(parsed, 'from-env'));
1297
+ } catch (error) {
1298
+ fail(error.message);
1299
+ }
1300
+ if (resolved.warning) console.error(`mcp-host: warning: ${resolved.warning}`);
1301
+ value = resolved.value;
1302
+ } else if (explicit !== undefined) {
1303
+ value = explicit;
1304
+ } else if (process.stdin.isTTY) {
1305
+ // A terminal and no value: ask for it without echo, so the secret
1306
+ // stays out of the shell history, out of `ps`, and off the screen.
1307
+ requireEnv();
1308
+ const prompter = makePrompter();
1309
+ try {
1310
+ value = await prompter.secret(prompt);
1311
+ } finally {
1312
+ prompter.close();
1313
+ }
1314
+ } else {
1315
+ value = await readStdin();
1316
+ }
1317
+ if (!value) fail('provide --value, --from-env [VAR], or pipe the secret on stdin');
1318
+ return value;
1319
+ }
1320
+
1321
+ function need(value, what) {
1322
+ if (!value) fail(`${what} is required`);
1323
+ return value;
1324
+ }
1325
+
1326
+ async function readStdin() {
1327
+ if (process.stdin.isTTY) return '';
1328
+ const chunks = [];
1329
+ for await (const chunk of process.stdin) chunks.push(chunk);
1330
+ return Buffer.concat(chunks).toString('utf8').trim();
1331
+ }
1332
+
1333
+ await main();