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