@chrischall/mcp-host-cli 2.33.0 → 2.34.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/README.md CHANGED
@@ -24,3 +24,24 @@ bearer token.
24
24
  Its version tracks the mcp-host release it was cut from, and its commands
25
25
  track that release's control API — keep it current with the deployment you
26
26
  point it at.
27
+
28
+ ## Upgrading from 2.33.0
29
+
30
+ The release after 2.33.0 is stricter in two ways a script can notice. Both
31
+ turn a silent mistake into an error, so they ship as a fix rather than a
32
+ major version:
33
+
34
+ - **Every command refuses a flag it does not take.** Before, ten commands
35
+ (`set`, `register`, `connector`, `build`, `preview`, `bundles`, `infra`,
36
+ `secret`, `marketplace`, `bridge`) dropped an unknown flag and ran anyway —
37
+ `set reg_1 --denny destructive` sent the update without the policy you
38
+ typed. Now it exits 1 with `unknown flag --denny` and the command's usage.
39
+ A script that passed a flag the command never read (for example
40
+ `preview --follow`, `secret rm --legacy`, `infra sync --days`) must drop it.
41
+ - **`approvals approve <id>` asks before it approves.** It shows the tool,
42
+ the registration and the arguments, then needs a "y" at a terminal. Without
43
+ a terminal (a pipe, CI, a cron job) it approves nothing and exits 1 unless
44
+ you pass `--yes`, so add `--yes` to any script that approves calls.
45
+
46
+ An unreachable gateway now prints one `mcp-host:` line and exits 1 instead of
47
+ a `fetch failed` stack trace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/mcp-host-cli",
3
- "version": "2.33.0",
3
+ "version": "2.34.0",
4
4
  "type": "module",
5
5
  "description": "Admin CLI for mcp-host: register, list, inspect, and manage hosted MCPs via the gateway control API.",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -35,7 +35,7 @@
35
35
  // mcp-host prewarm <id> | restart <id> | rm <id>
36
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
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
38
+ // mcp-host secret set NAME --value ... | secret ls | secret rm NAME [--force]
39
39
  // # sealed to the account's runner key BEFORE it leaves this process (docs/SEALED-SECRETS.md);
40
40
  // # prints the fingerprint to compare later. --legacy sends the deprecated gateway-readable body.
41
41
  // mcp-host secret set NAME # on a terminal: prompts, without echo
@@ -62,6 +62,7 @@ import {
62
62
  AGGREGATE_MODES,
63
63
  INSECURE_FLAG,
64
64
  approvalLines,
65
+ describeApproval,
65
66
  MCPB_USAGE,
66
67
  buildDependencies,
67
68
  buildEnvMap,
@@ -84,7 +85,7 @@ import {
84
85
  wantsWizard,
85
86
  } from './lib.mjs';
86
87
  import { runLogin } from './login.mjs';
87
- import { flagName, helpFor, helpText, matchGeneric, pathParams, usageFor } from './operations.mjs';
88
+ import { COMMAND_FLAGS, commandUsage, flagName, helpFor, helpText, matchGeneric, pathParams, usageFor } from './operations.mjs';
88
89
  import { makePrompter } from './prompt.mjs';
89
90
  import { compareFingerprint, storeSecretSealed } from './secrets.mjs';
90
91
  import { runWizard } from './wizard.mjs';
@@ -128,6 +129,33 @@ class ApiError extends Error {
128
129
  }
129
130
  }
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
+
131
159
  /**
132
160
  * One request to the control API; a non-2xx THROWS an ApiError. This is the
133
161
  * variant the wizard gets, because the wizard has retry loops and a
@@ -137,7 +165,7 @@ class ApiError extends Error {
137
165
  */
138
166
  async function apiRequest(method, path, body) {
139
167
  requireEnv();
140
- const res = await fetch(`${BASE}${path}`, {
168
+ const { res, text } = await exchange(`${BASE}${path}`, {
141
169
  method,
142
170
  headers: {
143
171
  authorization: `Bearer ${TOKEN}`,
@@ -145,7 +173,6 @@ async function apiRequest(method, path, body) {
145
173
  },
146
174
  body: body !== undefined ? JSON.stringify(body) : undefined,
147
175
  });
148
- const text = await res.text();
149
176
  let parsed;
150
177
  try {
151
178
  parsed = JSON.parse(text);
@@ -156,12 +183,12 @@ async function apiRequest(method, path, body) {
156
183
  return parsed;
157
184
  }
158
185
 
159
- /** `apiRequest` for the one-shot commands: a non-2xx is `fail()`, exit 1. */
186
+ /** `apiRequest` for the one-shot commands: a non-2xx, or no answer at all, is `fail()`, exit 1. */
160
187
  async function api(method, path, body) {
161
188
  try {
162
189
  return await apiRequest(method, path, body);
163
190
  } catch (error) {
164
- if (error instanceof ApiError) return fail(error.message);
191
+ if (error instanceof ApiError || error instanceof GatewayUnreachable) return fail(error.message);
165
192
  throw error;
166
193
  }
167
194
  }
@@ -186,7 +213,7 @@ async function uploadBundle(file) {
186
213
  } catch (error) {
187
214
  throw new Error(`--mcpb: cannot read ${file}: ${error.message}`);
188
215
  }
189
- const res = await fetch(`${BASE}/api/v1/bundles`, {
216
+ const { res, text } = await exchange(`${BASE}/api/v1/bundles`, {
190
217
  method: 'PUT',
191
218
  headers: {
192
219
  authorization: `Bearer ${TOKEN}`,
@@ -195,7 +222,6 @@ async function uploadBundle(file) {
195
222
  },
196
223
  body: bytes,
197
224
  });
198
- const text = await res.text();
199
225
  let parsed;
200
226
  try {
201
227
  parsed = JSON.parse(text);
@@ -547,18 +573,11 @@ function coerceField(flag, type, raw) {
547
573
  */
548
574
  async function runGeneric(row, argv) {
549
575
  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
- }
576
+ const booleans = Object.entries(body).filter(([, t]) => t === 'boolean').map(([f]) => flagName(f));
557
577
  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
- }
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);
562
581
  const args = row.args ?? [];
563
582
  if (parsed.positionals.length !== args.length) fail(usage);
564
583
 
@@ -600,10 +619,7 @@ async function runGeneric(row, argv) {
600
619
  * portal are for.
601
620
  */
602
621
  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
- }
622
+ const parsed = parseKnown(argv, 'logs');
607
623
  const id = need(parsed.positionals[0], 'id');
608
624
  const result = await api('GET', `/api/v1/registrations/${encodeURIComponent(id)}/logs${queryFor(parsed, ['lines'])}`);
609
625
  return print((result.logs ?? []).map((entry) => entry.line));
@@ -616,6 +632,46 @@ function refuseUnknownFlags(parsed, known, usage) {
616
632
  }
617
633
  }
618
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
+
619
675
  /**
620
676
  * `approvals [ls]`: the tool calls a policy said to ASK about (spec 2026-10-19
621
677
  * D3). The JSON on stdout is the listing as the API answered it; stderr gets
@@ -624,15 +680,10 @@ function refuseUnknownFlags(parsed, known, usage) {
624
680
  * expiry of the request they are about to approve should have to be found.
625
681
  */
626
682
  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
- }
683
+ if (argv[0] === 'approve') return approve(argv.slice(1));
684
+ const usage = commandUsage('approvals');
685
+ const parsed = parseKnown(argv, 'approvals');
634
686
  const names = ['status', 'account', ...listQuery()];
635
- refuseUnknownFlags(parsed, names.map(flagName), usage);
636
687
  const [sub, ...extra] = parsed.positionals;
637
688
  if ((sub !== undefined && sub !== 'ls') || extra.length > 0) fail(usage);
638
689
  const listed = await api('GET', `/api/v1/approvals${queryFor(parsed, names)}`);
@@ -648,6 +699,42 @@ async function approvals(argv) {
648
699
  }
649
700
  }
650
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
+
651
738
  /**
652
739
  * `me set --push-tenant-alerts on|off`: the owner's own switch for pushes
653
740
  * about their MCPs (spec 2026-10-19 D4). Every other `me …` is a generic row
@@ -655,19 +742,13 @@ async function approvals(argv) {
655
742
  * replaced took a body (#1171); the flag wins over it, as in profileWrite.
656
743
  */
657
744
  async function meSet(argv) {
658
- const usage = 'usage: mcp-host me set --push-tenant-alerts on|off [--data <json>]';
745
+ const usage = commandUsage('me set');
659
746
  const [sub, ...more] = argv;
660
747
  if (sub !== 'set') {
661
748
  console.error(helpText());
662
749
  return process.exit(1);
663
750
  }
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);
751
+ const parsed = parseKnown(more, 'me set');
671
752
  if (parsed.positionals.length > 0) fail(usage);
672
753
  const body = await readDataFlag(firstFlag(parsed, 'data'));
673
754
  const raw = firstFlag(parsed, 'push-tenant-alerts');
@@ -695,17 +776,8 @@ async function profileWrite(argv) {
695
776
  console.error(helpText());
696
777
  return process.exit(1);
697
778
  }
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);
779
+ const usage = commandUsage(`profile ${sub}`);
780
+ const parsed = parseKnown(more, `profile ${sub}`);
709
781
  const ids = parsed.positionals;
710
782
  if (ids.length !== (sub === 'add' ? 1 : 2)) fail(usage);
711
783
  const secretEnv = parsed.flags['secret-env'];
@@ -775,7 +847,7 @@ async function main() {
775
847
  return;
776
848
  }
777
849
  case 'register': {
778
- const parsed = parseArgs(rest, new Set(['follow', 'data-dir', 'no-data-dir', 'per-user-child', 'bridge']));
850
+ const parsed = parseKnown(rest, 'register');
779
851
  // No argv at all, and a person at a terminal: the wizard. Only then —
780
852
  // any flag at all means the flags path (`--account x` with no slug is
781
853
  // a mistyped command, not a request for questions that would discard
@@ -800,7 +872,7 @@ async function main() {
800
872
  // The ask is QUEUED (202, "building") unless --wait, which holds on for
801
873
  // the manifest — a github build with devDependencies can take minutes,
802
874
  // so waiting is the option: `mcp-host status <id>` shows the outcome.
803
- const parsed = parseArgs(rest, new Set(['force', 'wait']));
875
+ const parsed = parseKnown(rest, 'build');
804
876
  const id = need(parsed.positionals[0], 'id');
805
877
  const result = await api('POST', `/api/v1/registrations/${encodeURIComponent(id)}/build${queryFor(parsed, [], ['force', 'wait'])}`);
806
878
  if (result.accepted === true) {
@@ -818,10 +890,7 @@ async function main() {
818
890
  // keep a cache or a session under `~/` is losing it on every idle stop
819
891
  // (the runner says so on `status`), and the fix must not require
820
892
  // 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
- );
893
+ const parsed = parseKnown(rest, 'set');
825
894
  // From the positionals, never rest[0]: with the flag written first that
826
895
  // would take `--data-dir` for the id.
827
896
  const id = need(parsed.positionals[0], 'id');
@@ -971,7 +1040,7 @@ async function main() {
971
1040
  // env — and it writes nothing (docs/REGISTERING.md §Preview). The same
972
1041
  // source and --dep flags as `register`, read by the same code, so what
973
1042
  // the preview resolved is what the register will send.
974
- const parsed = parseArgs(rest);
1043
+ const parsed = parseKnown(rest, 'preview');
975
1044
  const dependencies = dependenciesFromFlags(parsed);
976
1045
  const source = await sourceFromFlags(parsed);
977
1046
  return print(
@@ -989,9 +1058,10 @@ async function main() {
989
1058
  // the same row. Scoped to the caller, never to the deployment: another
990
1059
  // account's identical bytes answer `existed: false`, because a
991
1060
  // fleet-wide flag is a hash oracle (2026-09-01 audit, finding 16).
992
- const parsed = parseArgs(rest);
1061
+ const parsed = parseCommand(rest, ['bundles upload']);
993
1062
  const [sub, file] = parsed.positionals;
994
1063
  if (sub === 'upload') {
1064
+ refuseFor(parsed, 'bundles upload');
995
1065
  const view = await uploadBundleOrFail(need(file, 'bundle path'));
996
1066
  print(view);
997
1067
  console.error(`\nRegister it: mcp-host register --slug <slug> --mcpb sha256:${view.sha256}`);
@@ -1005,8 +1075,9 @@ async function main() {
1005
1075
  // and the deployment total. Parse FIRST and read the subcommand from the
1006
1076
  // positionals, for the reason `marketplace` does: `infra --days 7` must
1007
1077
  // stay the view, not bind `7` as a verb.
1008
- const parsed = parseArgs(rest);
1078
+ const parsed = parseCommand(rest, ['infra', 'infra sync', 'infra drain', 'infra undrain']);
1009
1079
  const [sub, machineId] = parsed.positionals;
1080
+ if (sub === undefined || sub === 'sync' || sub === 'drain' || sub === 'undrain') refuseFor(parsed, sub ? `infra ${sub}` : 'infra');
1010
1081
  if (sub === 'sync') {
1011
1082
  // The pool's set is what the gateway PLACES over, so a machine that
1012
1083
  // `fly scale count` added serves nothing until it is synced in, and
@@ -1084,7 +1155,12 @@ async function main() {
1084
1155
  // fixed `rest.slice(2)` assumed every verb has a name, which `ls` does
1085
1156
  // not, so `secret ls --account x` would have dropped `--account`.
1086
1157
  const sub = rest[0];
1087
- const parsed = parseArgs(rest.slice(1), new Set(['legacy']), new Set(['from-env']));
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}`);
1088
1164
  const name = parsed.positionals[0];
1089
1165
  const account = firstFlag(parsed, 'account');
1090
1166
  if (sub === 'set' || sub === 'fingerprint') {
@@ -1138,11 +1214,9 @@ async function main() {
1138
1214
  return;
1139
1215
  }
1140
1216
  if (sub === 'rm') {
1141
- return print(await api('DELETE', `/api/v1/secrets/${encodeURIComponent(need(name, 'secret name'))}${queryFor(parsed, ['account'])}`));
1217
+ return print(await api('DELETE', `/api/v1/secrets/${encodeURIComponent(need(name, 'secret name'))}${queryFor(parsed, ['account'], ['force'])}`));
1142
1218
  }
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
- );
1219
+ return undefined;
1146
1220
  }
1147
1221
  case 'connector': {
1148
1222
  // The account's ONE connect URL (plan task G4, spec D8): the aggregate
@@ -1152,7 +1226,7 @@ async function main() {
1152
1226
  // what it reaches goes to stderr for the person reading. The short
1153
1227
  // account-free form where the gateway offers it — it resolves in the
1154
1228
  // calling account, so it is the one to paste — else the qualified one.
1155
- const parsed = parseArgs(rest, new Set(['json']));
1229
+ const parsed = parseKnown(rest, 'connector');
1156
1230
  const account = firstFlag(parsed, 'account');
1157
1231
  const answer = await api('GET', `/api/v1/connector${account ? `?account=${encodeURIComponent(account)}` : ''}`);
1158
1232
  if (parsed.booleans.has('json')) return print(answer);
@@ -1178,8 +1252,9 @@ async function main() {
1178
1252
  // parseArgs consumes each flag's value, so `marketplace --account x`
1179
1253
  // yields no positional (status form) rather than binding `--account` —
1180
1254
  // or, as a hand-rolled scan here once did, `x` — as the subcommand.
1181
- const parsed = parseArgs(rest);
1255
+ const parsed = parseCommand(rest, ['marketplace', 'marketplace rotate', 'marketplace revoke']);
1182
1256
  const [sub] = parsed.positionals;
1257
+ if (sub === undefined || sub === 'rotate' || sub === 'revoke') refuseFor(parsed, sub ? `marketplace ${sub}` : 'marketplace');
1183
1258
  const account = firstFlag(parsed, 'account');
1184
1259
  const query = account ? `?account=${encodeURIComponent(account)}` : '';
1185
1260
  if (sub === 'rotate') {
@@ -1214,8 +1289,10 @@ async function main() {
1214
1289
  // cannot mint one — since D18 a browser connects from its own extension
1215
1290
  // (Connect in the popup, one Confirm on the gateway page), and the old
1216
1291
  // mint answers 410.
1217
- const parsed = parseArgs(rest);
1292
+ const parsed = parseCommand(rest, ['bridge', 'bridge revoke']);
1218
1293
  const [sub, ...words] = parsed.positionals;
1294
+ if (sub === 'revoke') refuseFor(parsed, 'bridge revoke');
1295
+ else if (sub === undefined || sub === 'ls') refuseFor(parsed, 'bridge');
1219
1296
  const account = firstFlag(parsed, 'account');
1220
1297
  const query = account ? `?account=${encodeURIComponent(account)}` : '';
1221
1298
  if (sub === 'new' || sub === 'pair') {
@@ -1244,7 +1321,7 @@ async function main() {
1244
1321
  // `connections …` is a generic row (operations.mjs).
1245
1322
  const [sub, ...more] = rest;
1246
1323
  if (sub === 'login') {
1247
- const parsed = parseArgs(more);
1324
+ const parsed = parseKnown(more, 'connections login');
1248
1325
  const id = need(parsed.positionals[0], 'principal id');
1249
1326
  const interactive = Boolean(process.stdin.isTTY);
1250
1327
  const prompter = interactive ? makePrompter() : undefined;
@@ -1264,7 +1341,7 @@ async function main() {
1264
1341
  // documented gateway-SEALED exceptions (docs/SEALED-SECRETS.md), as the
1265
1342
  // portal sends it — and read like `secret set`'s, off the command line
1266
1343
  // only when `--value` says so.
1267
- const parsed = parseArgs(more.slice(1), new Set(), new Set(['from-env']));
1344
+ const parsed = parseKnown(more.slice(1), 'connections secret set');
1268
1345
  const [id, name] = parsed.positionals;
1269
1346
  need(id, 'principal id');
1270
1347
  need(name, 'credential name');
@@ -1330,4 +1407,12 @@ async function readStdin() {
1330
1407
  return Buffer.concat(chunks).toString('utf8').trim();
1331
1408
  }
1332
1409
 
1333
- await main();
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
+ }
package/src/lib.mjs CHANGED
@@ -510,23 +510,33 @@ function timeLeft(ms) {
510
510
  */
511
511
  export function approvalLines(approvals, now = Date.now()) {
512
512
  return approvals.map((a) => {
513
- const where = `${a.registrationSlug ?? a.registrationId} · ${a.tool}`;
514
- const preview =
515
- typeof a.argsPreview === 'string' && a.argsPreview.length > ARGS_PREVIEW_CHARS
516
- ? `${a.argsPreview.slice(0, ARGS_PREVIEW_CHARS - 1)}…`
517
- : (a.argsPreview ?? '');
518
- let when;
519
- if (a.status === 'pending' || a.status === 'approved') {
520
- const left = Date.parse(a.expiresAt) - now;
521
- when = Number.isFinite(left) && left > 0 ? `expires in ${timeLeft(left)}` : 'expired';
522
- if (a.status === 'approved') when = `approved${a.decidedBy ? ` by ${a.decidedBy}` : ''}, ${when}`;
523
- } else {
524
- when = a.decidedBy ? `${a.status} by ${a.decidedBy}` : String(a.status);
525
- }
513
+ const { where, preview, when } = describeApproval(a, now);
526
514
  return ` ${String(a.id).padEnd(14)} ${String(a.status).padEnd(9)} ${where} ${preview} ${when}`;
527
515
  });
528
516
  }
529
517
 
518
+ /**
519
+ * One approval's parts as a person reads them — which MCP and tool, the
520
+ * argument preview (shortened), and the time left or who decided — shared by
521
+ * the `approvals` listing and the echo `approvals approve` prints first.
522
+ */
523
+ export function describeApproval(a, now = Date.now()) {
524
+ const where = `${a.registrationSlug ?? a.registrationId} · ${a.tool}`;
525
+ const preview =
526
+ typeof a.argsPreview === 'string' && a.argsPreview.length > ARGS_PREVIEW_CHARS
527
+ ? `${a.argsPreview.slice(0, ARGS_PREVIEW_CHARS - 1)}…`
528
+ : (a.argsPreview ?? '');
529
+ let when;
530
+ if (a.status === 'pending' || a.status === 'approved') {
531
+ const left = Date.parse(a.expiresAt) - now;
532
+ when = Number.isFinite(left) && left > 0 ? `expires in ${timeLeft(left)}` : 'expired';
533
+ if (a.status === 'approved') when = `approved${a.decidedBy ? ` by ${a.decidedBy}` : ''}, ${when}`;
534
+ } else {
535
+ when = a.decidedBy ? `${a.status} by ${a.decidedBy}` : String(a.status);
536
+ }
537
+ return { where, preview, when };
538
+ }
539
+
530
540
  /**
531
541
  * `on`/`off` (or `true`/`false`, `yes`/`no`) as a boolean, for a switch flag
532
542
  * that must be able to say false — a bare boolean flag can only say true.
@@ -133,7 +133,13 @@ export const OPERATIONS = [
133
133
  // and applies nothing — acting on an ask is still `set` or a PUT
134
134
  // (docs/MINT-MANIFEST.md §9).
135
135
  { op: 'POST /api/v1/registrations/{id}/manifest-check', words: ['manifest-check'], args: ['id'], summary: 'read this registration\'s mint.yaml now' },
136
- { op: 'POST /api/v1/registrations/{id}/manifest-ack', words: ['manifest-ack'], args: ['id'], summary: 'record that the package\'s asks have been read' },
136
+ {
137
+ op: 'POST /api/v1/registrations/{id}/manifest-ack',
138
+ words: ['manifest-ack'],
139
+ args: ['id'],
140
+ body: { digest: 'string' },
141
+ summary: 'record that the package\'s asks carrying --digest have been read (409 if it is not the current one)',
142
+ },
137
143
  // Accepting a `losing state` warning for the files it names — no flag
138
144
  // flips and nothing restarts; `--names '[".cache"]'` names what is accepted,
139
145
  // and without it the open finding's names are (core `state-loss-ack.ts`).
@@ -254,7 +260,18 @@ export const OPERATIONS = [
254
260
  summary: 'tool calls waiting for approval: MCP, tool, argument preview, time left',
255
261
  example: ['approvals', 'ls'],
256
262
  },
257
- { op: 'POST /api/v1/approvals/{id}/approve', words: ['approvals', 'approve'], args: ['id'], query: ['account'], summary: 'let one waiting tool call through, once' },
263
+ // Bespoke so that it SHOWS the call before letting it through (fleet-audit
264
+ // #1454): an id pasted from a notification was approved without this
265
+ // terminal ever naming the MCP, the tool or the arguments.
266
+ {
267
+ op: 'POST /api/v1/approvals/{id}/approve',
268
+ bespoke: true,
269
+ words: ['approvals', 'approve'],
270
+ usage: 'approvals approve <id> [--account slug] [--yes] (shows the call first; asks, unless --yes)',
271
+ query: ['account'],
272
+ summary: 'let one waiting tool call through, once, after showing which MCP, tool and arguments',
273
+ example: ['approvals', 'approve', 'apr_1', '--yes'],
274
+ },
258
275
  { op: 'POST /api/v1/approvals/{id}/refuse', words: ['approvals', 'refuse'], args: ['id'], query: ['account'], summary: 'refuse one waiting tool call' },
259
276
 
260
277
  // ── connections (principals) ─────────────────────────────────────────────
@@ -298,7 +315,7 @@ export const OPERATIONS = [
298
315
  op: 'PUT /api/v1/me',
299
316
  bespoke: true,
300
317
  words: ['me', 'set'],
301
- usage: 'me set --push-tenant-alerts on|off',
318
+ usage: 'me set --push-tenant-alerts on|off [--data <json>]',
302
319
  summary: 'set your own preferences (whether alerts about your MCPs are pushed to your devices)',
303
320
  example: ['me', 'set', '--push-tenant-alerts', 'off'],
304
321
  },
@@ -354,9 +371,10 @@ export const OPERATIONS = [
354
371
  {
355
372
  op: 'DELETE /api/v1/secrets/{name}',
356
373
  bespoke: true,
357
- usage: 'secret rm <NAME> [--account slug]',
374
+ usage: 'secret rm <NAME> [--force] [--account slug] (--force deletes one a registration or profile still uses)',
358
375
  query: ['account'],
359
- summary: 'delete a secret',
376
+ booleanQuery: ['force'],
377
+ summary: 'delete a secret (refused while in use unless --force)',
360
378
  example: ['secret', 'rm', 'NAME'],
361
379
  },
362
380
  { op: 'GET /api/v1/secrets/public-key', words: ['secret', 'public-key'], query: ['account'], summary: 'the account seal key' },
@@ -476,6 +494,65 @@ export const OPERATIONS = [
476
494
  },
477
495
  ];
478
496
 
497
+ const kebabList = (prefix = '') => LIST.map((n) => flagName(prefix ? `${prefix}${n[0].toUpperCase()}${n.slice(1)}` : n));
498
+
499
+ /**
500
+ * The flags each HAND-WRITTEN command accepts (fleet-audit#1407), keyed by the
501
+ * words that name it. A generic row's flags are derived from the row; a
502
+ * bespoke command's were whatever its code happened to read, so a typo beside
503
+ * a valid flag was dropped and the request still sent. index.mjs parses each
504
+ * of these commands against its entry and REFUSES anything outside it, with
505
+ * the usage line of the row named by `op`.
506
+ *
507
+ * `values` take a value, `booleans` take none, `optional` may take one
508
+ * (`--from-env [VAR]`). Held to an enumeration in tests/bespoke-flags.test.ts.
509
+ *
510
+ * @type {Record<string, { op: string, values?: string[], booleans?: string[], optional?: string[] }>}
511
+ */
512
+ export const COMMAND_FLAGS = {
513
+ register: {
514
+ op: 'POST /api/v1/registrations',
515
+ values: ['slug', 'npm', 'github', 'pypi', 'mcpb', 'subdir', 'build', 'bin', 'dep', 'dep-sha256', 'account', 'name', 'env', 'secret-env', 'tools', 'auth-flow', 'data'],
516
+ booleans: ['follow', 'data-dir', 'no-data-dir', 'per-user-child', 'bridge'],
517
+ },
518
+ set: {
519
+ op: 'PUT /api/v1/registrations/{id}',
520
+ values: ['data', 'state-store', 'dep', 'dep-sha256', 'bridge-approval', 'deny', 'ask', 'tools', 'aggregate'],
521
+ booleans: ['data-dir', 'no-data-dir', 'no-deps', 'follow', 'no-follow', 'allow-all', 'no-ask', 'all-tools'],
522
+ },
523
+ logs: { op: 'GET /api/v1/registrations/{id}/logs', values: ['lines'] },
524
+ build: { op: 'POST /api/v1/registrations/{id}/build', booleans: ['force', 'wait'] },
525
+ preview: { op: 'POST /api/v1/registrations/preview', values: ['npm', 'github', 'pypi', 'mcpb', 'subdir', 'build', 'dep', 'dep-sha256'] },
526
+ 'bundles upload': { op: 'PUT /api/v1/bundles' },
527
+ approvals: { op: 'GET /api/v1/approvals', values: ['status', 'account', ...kebabList()] },
528
+ 'approvals approve': { op: 'POST /api/v1/approvals/{id}/approve', values: ['account'], booleans: ['yes'] },
529
+ 'me set': { op: 'PUT /api/v1/me', values: ['push-tenant-alerts', 'data'] },
530
+ 'profile add': { op: 'POST /api/v1/registrations/{id}/profiles', values: ['label', 'secret-env', 'env', 'account', 'data'] },
531
+ 'profile set': { op: 'PUT /api/v1/registrations/{id}/profiles/{profileId}', values: ['label', 'secret-env', 'env', 'account', 'data'] },
532
+ 'connections login': { op: 'POST /api/v1/principals/{id}/login' },
533
+ 'connections secret set': { op: 'PUT /api/v1/principals/{id}/secrets/{name}', values: ['value'], optional: ['from-env'] },
534
+ 'secret set': { op: 'PUT /api/v1/secrets/{name}', values: ['value', 'account'], booleans: ['legacy'], optional: ['from-env'] },
535
+ 'secret fingerprint': { op: 'GET /api/v1/secrets', values: ['value', 'account'], optional: ['from-env'] },
536
+ 'secret ls': { op: 'GET /api/v1/secrets', values: ['account', ...kebabList()] },
537
+ 'secret rm': { op: 'DELETE /api/v1/secrets/{name}', values: ['account'], booleans: ['force'] },
538
+ bridge: { op: 'GET /api/v1/bridge-tokens', values: ['account', ...kebabList()] },
539
+ 'bridge revoke': { op: 'DELETE /api/v1/bridge-tokens/{id}', values: ['account'] },
540
+ connector: { op: 'GET /api/v1/connector', values: ['account'], booleans: ['json'] },
541
+ marketplace: { op: 'GET /api/v1/marketplace', values: ['account'] },
542
+ 'marketplace rotate': { op: 'POST /api/v1/marketplace/token', values: ['account'] },
543
+ 'marketplace revoke': { op: 'DELETE /api/v1/marketplace/token', values: ['account'] },
544
+ infra: { op: 'GET /api/v1/infra', values: ['account', 'days', ...kebabList('res')] },
545
+ 'infra sync': { op: 'POST /api/v1/infra/shared-machines/sync' },
546
+ 'infra drain': { op: 'POST /api/v1/infra/shared-machines/{id}/drain' },
547
+ 'infra undrain': { op: 'POST /api/v1/infra/shared-machines/{id}/undrain' },
548
+ };
549
+
550
+ /** `usage: mcp-host …` for a COMMAND_FLAGS key: its row's usage line, so the refusal and `--help` say one thing. */
551
+ export function commandUsage(key) {
552
+ const row = OPERATIONS.find((r) => r.op === COMMAND_FLAGS[key].op);
553
+ return `usage: mcp-host ${usageFor(row)}`;
554
+ }
555
+
479
556
  /** `perPage` → `per-page`: the flag spelling of a query or body field name. */
480
557
  export function flagName(field) {
481
558
  return field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);