@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 +21 -0
- package/package.json +1 -1
- package/src/index.mjs +155 -70
- package/src/lib.mjs +23 -13
- package/src/operations.mjs +82 -5
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
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
|
|
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
|
|
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 =
|
|
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 =
|
|
559
|
-
|
|
560
|
-
|
|
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 =
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/operations.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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()}`);
|