@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/lib.mjs ADDED
@@ -0,0 +1,654 @@
1
+ // Pure helpers for the mcp-host CLI — kept separate from the entry so tests
2
+ // import them without executing main().
3
+
4
+ import { createHmac } from 'node:crypto';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+
7
+ /**
8
+ * @param {string} spec e.g. "setlist-mcp", "setlist-mcp@1.2.3", "@scope/pkg@latest"
9
+ * @returns {{ type: 'npm', pkg: string, version?: string }}
10
+ */
11
+ export function parseNpmSpec(spec) {
12
+ const at = spec.lastIndexOf('@');
13
+ if (at > 0) {
14
+ return { type: 'npm', pkg: spec.slice(0, at), version: spec.slice(at + 1) };
15
+ }
16
+ return { type: 'npm', pkg: spec };
17
+ }
18
+
19
+ /**
20
+ * A PyPI project, spelled the way pip spells one: `pkg` or `pkg==version`
21
+ * (docs/PYPI-SOURCE.md). `==` rather than npm's `@`, because that is the
22
+ * separator a person reading Python packaging has in their hands — and because
23
+ * a PEP 440 version legitimately contains no `@` to disambiguate against.
24
+ *
25
+ * The NAME is normalised here (PEP 503: lower case, runs of `-`, `_` and `.`
26
+ * collapsed to one `-`), so `mcp-host register --pypi MarkItDown-MCP` works.
27
+ * The API refuses a non-normalised name outright, for a reason that would
28
+ * otherwise land on a person as an unexplained 3xx from PyPI: the JSON API
29
+ * answers a non-canonical name with a 301 and every pinning fetch refuses
30
+ * redirects. Normalising in the CLI is a convenience over that refusal, never a
31
+ * replacement for it.
32
+ *
33
+ * @param {string} spec e.g. "markitdown-mcp", "markitdown-mcp==0.0.1a4"
34
+ * @returns {{ type: 'pypi', pkg: string, version?: string }}
35
+ */
36
+ export function parsePypiSpec(spec) {
37
+ const at = spec.indexOf('==');
38
+ const rawPkg = at > 0 ? spec.slice(0, at) : spec;
39
+ const version = at > 0 ? spec.slice(at + 2) : undefined;
40
+ const pkg = rawPkg.trim().replace(/[-_.]+/g, '-').toLowerCase();
41
+ if (pkg === '' || version === '') {
42
+ throw new Error(`--pypi expects <pkg> or <pkg>==<version>, got ${JSON.stringify(spec)}`);
43
+ }
44
+ return { type: 'pypi', pkg, ...(version !== undefined ? { version } : {}) };
45
+ }
46
+
47
+ /**
48
+ * @param {string} spec e.g. "owner/repo", "owner/repo#main", "owner/repo#<sha>"
49
+ * @returns {{ type: 'github', repo: string, ref?: string, commit?: string }}
50
+ */
51
+ export function parseGithubSpec(spec) {
52
+ const [repo, ref] = spec.split('#');
53
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '')) {
54
+ throw new Error(`--github expects owner/repo[#ref], got ${JSON.stringify(spec)}`);
55
+ }
56
+ const out = { type: 'github', repo };
57
+ if (ref) {
58
+ if (/^[0-9a-f]{40}$/.test(ref)) out.commit = ref;
59
+ else out.ref = ref;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** The three spellings `--mcpb` takes, quoted in the usage line and in every refusal. */
65
+ export const MCPB_USAGE = '--mcpb <path|https-url|sha256:hex>';
66
+
67
+ /**
68
+ * `--mcpb <spec>`: WHICH of the three things it names, decided here and
69
+ * nowhere else so `register`, `preview` and the wizard cannot read one
70
+ * spelling three ways.
71
+ *
72
+ * Judged in this order, because the forms overlap: an `https://` URL first
73
+ * (nothing on disk is named that); then a file that EXISTS, checked before
74
+ * the hash shapes so a bundle saved under its own hash as a filename is
75
+ * still the file; then `sha256:<64hex>` or a bare 64-hex, which names a
76
+ * bundle already uploaded (`mcp-host bundles upload` prints it). `http://`
77
+ * is refused rather than passed on: the gateway's input schema refuses it
78
+ * too, and the CLI's sentence can say "https" where a 400 says "invalid".
79
+ *
80
+ * Nothing is READ here: a path is only checked for existence, so the parser
81
+ * stays pure enough to test with an injected `exists` and the upload stays
82
+ * the one place the bytes are touched.
83
+ *
84
+ * @param {string} spec
85
+ * @param {(file: string) => boolean} exists
86
+ * @returns {{kind:'url', url:string} | {kind:'path', path:string} | {kind:'sha256', sha256:string}}
87
+ */
88
+ export function parseMcpbSpec(spec, exists = existsSync) {
89
+ if (!spec) throw new Error(`${MCPB_USAGE} names nothing`);
90
+ if (spec.startsWith('https://')) return { kind: 'url', url: spec };
91
+ if (spec.startsWith('http://')) throw new Error(`--mcpb: a bundle URL must be https, got ${JSON.stringify(spec)}`);
92
+ if (exists(spec)) return { kind: 'path', path: spec };
93
+ if (spec.startsWith('sha256:')) {
94
+ const hex = spec.slice('sha256:'.length);
95
+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`--mcpb sha256: expected 64 hex characters (lower-case sha256), got ${JSON.stringify(hex)}`);
96
+ return { kind: 'sha256', sha256: hex };
97
+ }
98
+ if (/^[0-9a-f]{64}$/.test(spec)) return { kind: 'sha256', sha256: spec };
99
+ throw new Error(`--mcpb: no such file ${spec} (a bundle is a .mcpb on disk, an https:// URL, or sha256:<hex> of one already uploaded)`);
100
+ }
101
+
102
+ /** The one spelling of the flag, quoted in the refusal it is the way out of. */
103
+ export const INSECURE_FLAG = '--insecure';
104
+
105
+ /**
106
+ * Is `MCP_HOST_URL` somewhere the deployment's admin bearer may be sent?
107
+ *
108
+ * Every request this CLI makes carries `Authorization: Bearer
109
+ * <MCP_HOST_ADMIN_TOKEN>`, the break-glass credential for the whole
110
+ * deployment, so the URL's SCHEME decides whether that credential crosses the
111
+ * wire in clear. An `http://` gateway — a typo, or a captive portal answering
112
+ * for one — hands it to anyone on the path, and the 301 to https a real
113
+ * gateway then sends is too late: the first request already carried it (beta
114
+ * review 2026-09-10, admin-ui-cli LOW-3).
115
+ *
116
+ * `localhost` is NOT special-cased, deliberately. A loopback exception is read
117
+ * off the HOST, and the host is the part an attacker chooses — a name
118
+ * resolving to 127.0.0.1 is one DNS record — so the exception would be a
119
+ * shape the tool trusts rather than a decision somebody made. `--insecure` is
120
+ * the one way out, and it is what a development gateway on
121
+ * `http://localhost:8787` passes: a statement per invocation.
122
+ *
123
+ * Returns the sentence to refuse with, or null to go ahead.
124
+ *
125
+ * @param {string} base the gateway URL, trailing slash already stripped
126
+ * @param {boolean} insecure was `--insecure` passed
127
+ * @returns {string | null}
128
+ */
129
+ export function insecureGatewayRefusal(base, insecure) {
130
+ let url;
131
+ try {
132
+ url = new URL(base);
133
+ } catch {
134
+ return `MCP_HOST_URL is not a URL: ${JSON.stringify(base)} (expected https://<host>)`;
135
+ }
136
+ if (url.protocol === 'https:') return null;
137
+ if (url.protocol !== 'http:') {
138
+ return `MCP_HOST_URL must be an https:// URL, got ${url.protocol}// — the admin token is sent to whatever it names`;
139
+ }
140
+ if (insecure) return null;
141
+ return (
142
+ `MCP_HOST_URL is ${url.protocol}//${url.host}: the admin token would cross the wire in clear. ` +
143
+ `Use https://, or pass ${INSECURE_FLAG} for a development gateway.`
144
+ );
145
+ }
146
+
147
+ /** The `--dep` grammar, quoted in every refusal so the fix is in the error. */
148
+ export const DEP_GRAMMAR =
149
+ '--dep github:<owner/repo>[@<tag|latest>]:<asset-name-or-glob>#<bin>[,<bin>…] | --dep npm:<pkg>[@<version|dist-tag>]';
150
+
151
+ function badDep(spec, why) {
152
+ return new Error(`--dep ${why}, got ${JSON.stringify(spec)}; expected ${DEP_GRAMMAR}`);
153
+ }
154
+
155
+ /**
156
+ * One `--dep` flag into the control API's unresolved dependency input
157
+ * (core `dependencyInputSchema`): the field names here ARE the wire's.
158
+ *
159
+ * Parsed from the RIGHT, because every delimiter also occurs inside the parts
160
+ * it separates: a scoped npm package carries an `@`, an asset glob may carry
161
+ * `.`/`+`, and a bin path carries `/`. So the last `#` splits the bins off,
162
+ * the last `:` before it splits the asset off, and only then is the `@` in
163
+ * what is left the tag. Nothing here is validated beyond the shape — the
164
+ * server's schema owns the grammar of each part (asset alphabet, bin path
165
+ * rules), and a copy of those rules would be a second source of truth.
166
+ *
167
+ * @param {string} spec
168
+ * @returns {{kind:'github-release', repo:string, tag:string, asset:string, bin:string[]}
169
+ * | {kind:'npm', pkg:string, version?:string}}
170
+ */
171
+ export function parseDepSpec(spec) {
172
+ const colon = spec.indexOf(':');
173
+ const kind = colon > 0 ? spec.slice(0, colon) : '';
174
+ const remainder = spec.slice(colon + 1);
175
+ if (kind === 'npm') {
176
+ if (remainder === '') throw badDep(spec, 'names no package');
177
+ const { pkg, version } = parseNpmSpec(remainder);
178
+ if (pkg === '' || version === '') throw badDep(spec, 'has an empty package or version');
179
+ return { kind: 'npm', pkg, ...(version !== undefined ? { version } : {}) };
180
+ }
181
+ if (kind !== 'github') throw badDep(spec, 'must start with github: or npm:');
182
+ const hash = remainder.lastIndexOf('#');
183
+ if (hash < 0) throw badDep(spec, 'names no bin (missing #<bin>)');
184
+ const bin = remainder.slice(hash + 1).split(',');
185
+ if (bin.some((b) => b === '')) throw badDep(spec, 'has an empty bin name');
186
+ const head = remainder.slice(0, hash);
187
+ const assetAt = head.lastIndexOf(':');
188
+ if (assetAt < 0) throw badDep(spec, 'names no asset (missing :<asset>)');
189
+ const asset = head.slice(assetAt + 1);
190
+ if (asset === '') throw badDep(spec, 'has an empty asset name');
191
+ const repoPart = head.slice(0, assetAt);
192
+ const at = repoPart.indexOf('@');
193
+ const repo = at < 0 ? repoPart : repoPart.slice(0, at);
194
+ const tag = at < 0 ? 'latest' : repoPart.slice(at + 1);
195
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw badDep(spec, 'needs an owner/repo');
196
+ if (tag === '') throw badDep(spec, 'has an empty tag');
197
+ return { kind: 'github-release', repo, tag, asset, bin };
198
+ }
199
+
200
+ /**
201
+ * Every `--dep` in order, with each `--dep-sha256 <asset>=<64hex>` attached to
202
+ * the github-release dep that names that asset. The sha256 is a separate flag
203
+ * rather than a fifth field in the spec because it is needed only when GitHub
204
+ * reports no digest — the common case never types it — and a 64-hex tail
205
+ * would make the one-line form unreadable. One that matches nothing is an
206
+ * error: silently dropping it would let a pin the owner typed go unchecked.
207
+ *
208
+ * @param {string[]} depArgs
209
+ * @param {string[]} depSha256Args
210
+ */
211
+ export function buildDependencies(depArgs, depSha256Args) {
212
+ const dependencies = depArgs.map(parseDepSpec);
213
+ for (const pair of depSha256Args) {
214
+ const eq = pair.indexOf('=');
215
+ if (eq <= 0) throw new Error(`--dep-sha256 expects <asset-name-or-glob>=<64hex>, got ${JSON.stringify(pair)}`);
216
+ const asset = pair.slice(0, eq);
217
+ const sha256 = pair.slice(eq + 1);
218
+ if (!/^[0-9a-f]{64}$/.test(sha256)) {
219
+ throw new Error(`--dep-sha256 ${asset}: expected 64 hex characters (lower-case sha256)`);
220
+ }
221
+ const dep = dependencies.find((d) => d.kind === 'github-release' && d.asset === asset);
222
+ if (!dep) throw new Error(`--dep-sha256 ${asset} matches no --dep github-release asset`);
223
+ dep.sha256 = sha256;
224
+ }
225
+ return dependencies;
226
+ }
227
+
228
+ /**
229
+ * The slug rule, for a proposed slug: a MIRROR of the validity rule in
230
+ * `packages/core/src/slug.ts` (the regex and the reserved list). The CLI has
231
+ * no workspace dependency on core (it is plain ESM with no build), so the
232
+ * rule is restated here; a change to core's rule is a change to this one.
233
+ */
234
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
235
+ const RESERVED_SLUGS = new Set([
236
+ 'api', 'c', 'mcp', 'sse', 'messages', 'authorize', 'token', 'register', 'admin', 'healthz',
237
+ 'media', 'static', 'assets', 'www', 'oauth', 'well-known', 'all',
238
+ ]);
239
+
240
+ /**
241
+ * How a registration takes part in its account's aggregate connector (`set
242
+ * --aggregate`, plan task G4): a MIRROR of `AGGREGATE_MODES` in
243
+ * `packages/core/src/aggregate.ts`, for the slug rule's reason above, held to
244
+ * it by `tests/aggregate-mirror.test.ts`.
245
+ */
246
+ export const AGGREGATE_MODES = Object.freeze(['inline', 'search', 'exclude']);
247
+
248
+ /**
249
+ * A slug proposed from a source name: `@scope/foo-mcp` → `foo`,
250
+ * `owner/gogcli-mcp` → `gogcli`. The portal's register wizard carries the
251
+ * same suggestion rule (`packages/admin-ui/src/slug.ts`), so the two propose
252
+ * the same name for the same package; core's validity rule is what both are
253
+ * checked against. Last path segment, lowercased, the `mcp-`/`-mcp` affixes dropped,
254
+ * anything outside the slug alphabet turned into a dash. Returns '' rather than
255
+ * a reserved or empty result: a suggestion the API would refuse is worse than
256
+ * none, because the field looks filled.
257
+ *
258
+ * @param {string} source
259
+ */
260
+ export function suggestSlug(source) {
261
+ const segment = source.slice(source.lastIndexOf('/') + 1).toLowerCase();
262
+ let slug = segment.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
263
+ slug = slug.replace(/^mcp-+/, '').replace(/-+mcp$/, '');
264
+ slug = slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
265
+ slug = slug.slice(0, 63).replace(/-+$/, '');
266
+ return SLUG_RE.test(slug) && !RESERVED_SLUGS.has(slug) ? slug : '';
267
+ }
268
+
269
+ /**
270
+ * Whether `mcp-host register` is the interactive wizard: a terminal AND no
271
+ * argv at all. A single flag — even one that names no slug or source, like
272
+ * `--account x` — is the flags path, because a wizard that silently dropped
273
+ * it would register under the default account without saying so; the flags
274
+ * path answers with the error (`--slug is required`) that names the fix.
275
+ *
276
+ * @param {string[]} argv everything after `register`
277
+ * @param {boolean | undefined} isTTY
278
+ */
279
+ export function wantsWizard(argv, isTTY) {
280
+ return Boolean(isTTY) && argv.length === 0;
281
+ }
282
+
283
+ /**
284
+ * Turns repeated --env K=V and --secret-env K=NAME flags into the control
285
+ * API's env map ({literal} | {secret: name}).
286
+ * @param {string[]} envArgs
287
+ * @param {string[]} secretEnvArgs
288
+ */
289
+ export function buildEnvMap(envArgs, secretEnvArgs) {
290
+ /** @type {Record<string, string | {secret: string}>} */
291
+ const env = {};
292
+ for (const pair of envArgs) {
293
+ const eq = pair.indexOf('=');
294
+ if (eq <= 0) throw new Error(`--env expects K=V, got ${JSON.stringify(pair)}`);
295
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
296
+ }
297
+ for (const pair of secretEnvArgs) {
298
+ const eq = pair.indexOf('=');
299
+ if (eq <= 0) throw new Error(`--secret-env expects VAR=SECRET_NAME, got ${JSON.stringify(pair)}`);
300
+ env[pair.slice(0, eq)] = { secret: pair.slice(eq + 1) };
301
+ }
302
+ return env;
303
+ }
304
+
305
+ /**
306
+ * Minimal flag parser: `--key value` (repeatable), `--flag` booleans,
307
+ * `--flag [value]` optionals, and positionals.
308
+ *
309
+ * An OPTIONAL-value flag takes the next token only when there is one and it
310
+ * does not itself look like a flag, so `--from-env` and `--from-env VAR` are
311
+ * both legal and `--from-env --value x` does not silently eat `--value`. When
312
+ * it appears without a value it lands in `booleans`, so `flagPresent` can
313
+ * answer "was it passed at all" without conflating the two spellings.
314
+ *
315
+ * @param {string[]} argv
316
+ * @param {Set<string>} booleans flag names that take no value
317
+ * @param {Set<string>} optionalValue flag names whose value may be omitted
318
+ */
319
+ export function parseArgs(argv, booleans = new Set(), optionalValue = new Set()) {
320
+ /** @type {{ positionals: string[], flags: Record<string, string[]>, booleans: Set<string> }} */
321
+ const out = { positionals: [], flags: {}, booleans: new Set() };
322
+ for (let i = 0; i < argv.length; i++) {
323
+ const arg = argv[i];
324
+ if (arg.startsWith('--')) {
325
+ const name = arg.slice(2);
326
+ if (booleans.has(name)) {
327
+ out.booleans.add(name);
328
+ } else if (optionalValue.has(name)) {
329
+ const next = argv[i + 1];
330
+ // A leading '-' means the next token is another flag, not our value.
331
+ // No environment variable is named like that, so nothing legitimate is
332
+ // lost by refusing to read one as a value.
333
+ if (next !== undefined && !next.startsWith('-')) {
334
+ (out.flags[name] ??= []).push(argv[++i]);
335
+ } else {
336
+ out.booleans.add(name);
337
+ }
338
+ } else {
339
+ const value = argv[++i];
340
+ if (value === undefined) throw new Error(`--${name} needs a value`);
341
+ (out.flags[name] ??= []).push(value);
342
+ }
343
+ } else {
344
+ out.positionals.push(arg);
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ export function firstFlag(parsed, name) {
351
+ return parsed.flags[name]?.[0];
352
+ }
353
+
354
+ /** Was `--name` passed at all — with a value or without one? */
355
+ export function flagPresent(parsed, name) {
356
+ return parsed.booleans.has(name) || parsed.flags[name] !== undefined;
357
+ }
358
+
359
+ /**
360
+ * Resolve a secret's value out of the environment for `secret set --from-env`.
361
+ *
362
+ * `varName` is what `--from-env VAR` supplied; omitted, the variable named
363
+ * after the SECRET is read, which is the common case (`KIA_PASSWORD` from
364
+ * `$KIA_PASSWORD`) and the reason the flag can be passed bare.
365
+ *
366
+ * Reading it here rather than in the shell is the point: the value never
367
+ * appears in argv, so it stays out of the shell history and out of `ps`.
368
+ *
369
+ * The value is passed through byte-for-byte. Trimming would be the friendly
370
+ * thing right up until it truncated a secret that legitimately ends in
371
+ * whitespace, and a corrupted credential fails later, elsewhere, as an
372
+ * authentication error nobody connects back to here. Surrounding whitespace is
373
+ * warned about instead, because a stray newline is the likelier reading of it.
374
+ *
375
+ * @param {string} secretName the secret being set
376
+ * @param {string | undefined} varName explicit variable name, if given
377
+ * @param {Record<string, string | undefined>} env
378
+ */
379
+ export function readSecretEnv(secretName, varName, env = process.env) {
380
+ const key = varName ?? secretName;
381
+ const value = env[key];
382
+ if (value === undefined) {
383
+ throw new Error(
384
+ `--from-env: $${key} is not set` +
385
+ (varName ? '' : ' (pass --from-env <VAR> to read a differently-named variable)'),
386
+ );
387
+ }
388
+ if (value === '') throw new Error(`--from-env: $${key} is empty`);
389
+ const warning =
390
+ value !== value.trim()
391
+ // `characters`, not `bytes`: .length counts UTF-16 code units, so a
392
+ // secret with any non-ASCII in it would be reported at the wrong size —
393
+ // and a size is exactly what someone reads to decide whether the
394
+ // whitespace they are being warned about is the whole value.
395
+ ? `$${key} has leading or trailing whitespace; storing it verbatim (${value.length} characters)`
396
+ : undefined;
397
+ return { key, value, warning };
398
+ }
399
+
400
+ /**
401
+ * Turns `--tools a,b` into the enabledTools allowlist, or null when the flag is
402
+ * absent (which means "no narrowing").
403
+ *
404
+ * Blank entries are dropped rather than sent: a trailing comma used to ship a
405
+ * '' tool name, and `--tools ""` an allowlist of one empty name. Both matter
406
+ * more than they look — enabledTools is a capability gate, and a narrowed one
407
+ * also switches off prompts and resources, so a shell mishap must not quietly
408
+ * become a registration. A flag that names nothing at all is an error, not an
409
+ * empty allowlist and not a silent widening back to null.
410
+ *
411
+ * @param {string | undefined} value
412
+ * @param {string} [flag] the flag the value came from, for the refusal
413
+ * @returns {string[] | null}
414
+ */
415
+ export function parseToolList(value, flag = '--tools') {
416
+ if (value === undefined) return null;
417
+ const tools = value
418
+ .split(',')
419
+ .map((name) => name.trim())
420
+ .filter((name) => name !== '');
421
+ if (tools.length === 0) throw new Error(`${flag} names no tools, got ${JSON.stringify(value)}`);
422
+ return tools;
423
+ }
424
+
425
+ /** Mirror of core `TOOL_POLICY_DENY_CLASSES`: what `--deny` may name. */
426
+ export const TOOL_POLICY_DENY_CLASSES = ['destructive', 'non-read-only'];
427
+
428
+ /**
429
+ * What `set`'s policy flags ask to change, half by half, or null when none was
430
+ * passed. `--deny` (repeatable, or comma-separated) REPLACES the deny classes
431
+ * and `--allow-all` clears them; `--ask a,b` (repeatable) REPLACES the
432
+ * ask-me-first list and `--no-ask` clears it. A half no flag named is absent
433
+ * from the answer, so `mergeToolPolicy` keeps the stored one: the PUT takes the
434
+ * policy WHOLE, and `--deny destructive` must not empty an ask list nobody
435
+ * mentioned. Naming both flags of a pair is a mistake, not a precedence
436
+ * question — the `--dep`/`--no-deps` rule.
437
+ *
438
+ * @param {{ flags: Record<string, string[]>, booleans: Set<string> }} parsed
439
+ * @returns {{ deny?: string[], ask?: string[] } | null}
440
+ */
441
+ export function toolPolicyChange(parsed) {
442
+ /** @type {{ deny?: string[], ask?: string[] }} */
443
+ const change = {};
444
+ const denyValues = parsed.flags['deny'];
445
+ const allowAll = parsed.booleans.has('allow-all');
446
+ if (denyValues !== undefined && allowAll) throw new Error('pass --deny or --allow-all, not both');
447
+ if (denyValues !== undefined) {
448
+ const named = denyValues.flatMap((value) => value.split(',')).map((cls) => cls.trim());
449
+ for (const cls of named) {
450
+ if (!TOOL_POLICY_DENY_CLASSES.includes(cls)) {
451
+ throw new Error(`--deny takes destructive or non-read-only (comma-separate for both), got ${JSON.stringify(cls)}`);
452
+ }
453
+ }
454
+ change.deny = [...new Set(named)];
455
+ } else if (allowAll) {
456
+ change.deny = [];
457
+ }
458
+ const askValues = parsed.flags['ask'];
459
+ const noAsk = parsed.booleans.has('no-ask');
460
+ if (askValues !== undefined && noAsk) throw new Error('pass --ask or --no-ask, not both');
461
+ if (askValues !== undefined) {
462
+ change.ask = [...new Set(askValues.flatMap((value) => parseToolList(value, '--ask') ?? []))];
463
+ } else if (noAsk) {
464
+ change.ask = [];
465
+ }
466
+ return Object.keys(change).length === 0 ? null : change;
467
+ }
468
+
469
+ /**
470
+ * The whole policy a PUT sends: the halves `change` names, over the stored
471
+ * policy (the registration view's `toolPolicy`). A stored half that is not a
472
+ * string array — absent on an older gateway — reads as empty.
473
+ *
474
+ * @param {unknown} stored
475
+ * @param {{ deny?: string[], ask?: string[] }} change
476
+ * @returns {{ deny: string[], ask: string[] }}
477
+ */
478
+ export function mergeToolPolicy(stored, change) {
479
+ const half = (value) =>
480
+ Array.isArray(value) && value.every((entry) => typeof entry === 'string') ? [...value] : [];
481
+ const current = typeof stored === 'object' && stored !== null ? stored : {};
482
+ return {
483
+ deny: change.deny ?? half(current.deny),
484
+ ask: change.ask ?? half(current.ask),
485
+ };
486
+ }
487
+
488
+ /** How much of an approval's argument preview a summary line shows. */
489
+ const ARGS_PREVIEW_CHARS = 80;
490
+
491
+ /** `4m`, `20s`, `1h 5m`: the time left in an approval window, coarsely. */
492
+ function timeLeft(ms) {
493
+ const s = Math.floor(ms / 1000);
494
+ if (s < 60) return `${s}s`;
495
+ const m = Math.floor(s / 60);
496
+ if (m < 60) return `${m}m`;
497
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
498
+ }
499
+
500
+ /**
501
+ * One line per approval row, for a person deciding (`mcp-host approvals`):
502
+ * which MCP, which tool, the argument preview the gateway kept (already
503
+ * bounded and redacted server-side; shortened again here to fit a line), and
504
+ * for a request still open the time left in its window. A decided row says
505
+ * who decided it. Stdout stays the JSON; these lines are stderr's.
506
+ *
507
+ * @param {Array<Record<string, any>>} approvals
508
+ * @param {number} now epoch ms
509
+ * @returns {string[]}
510
+ */
511
+ export function approvalLines(approvals, now = Date.now()) {
512
+ return approvals.map((a) => {
513
+ const { where, preview, when } = describeApproval(a, now);
514
+ return ` ${String(a.id).padEnd(14)} ${String(a.status).padEnd(9)} ${where} ${preview} ${when}`;
515
+ });
516
+ }
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
+
540
+ /**
541
+ * `on`/`off` (or `true`/`false`, `yes`/`no`) as a boolean, for a switch flag
542
+ * that must be able to say false — a bare boolean flag can only say true.
543
+ *
544
+ * @param {string} value
545
+ * @param {string} flag for the refusal
546
+ */
547
+ export function parseOnOff(value, flag) {
548
+ const v = value.trim().toLowerCase();
549
+ if (v === 'on' || v === 'true' || v === 'yes') return true;
550
+ if (v === 'off' || v === 'false' || v === 'no') return false;
551
+ throw new Error(`${flag} takes on or off, got ${JSON.stringify(value)}`);
552
+ }
553
+
554
+ /**
555
+ * A nested JSON document named by a flag, e.g. `--auth-flow ./kia-flow.json`.
556
+ *
557
+ * A path rather than an inline string, because the documents this reads are
558
+ * nested: an authFlow is steps, arguments, `{from:}` references and prompts,
559
+ * all quotes and braces, and a shell is the worst possible place to assemble
560
+ * one. A file is also reviewable and diffable, which a login definition
561
+ * deserves — it decides what a person is asked for their password.
562
+ *
563
+ * The GRAMMAR is not checked here. The one schema that owns it lives in
564
+ * packages/core and runs on the write, so a copy of its rules in the CLI would
565
+ * be a second source of truth that drifts. What is checked is only what the
566
+ * server's error could not explain as well from here: which file, and that it
567
+ * held an object at all — a bare steps array (the `{ "steps": ... }` wrapper
568
+ * forgotten) is the near-miss worth naming plainly.
569
+ *
570
+ * @param {string} file path to the document
571
+ * @param {string} flag the flag that named it, for the error message
572
+ * @param {(path: string, encoding: 'utf8') => string} read
573
+ */
574
+ export function readJsonDocument(file, flag, read = readFileSync) {
575
+ let text;
576
+ try {
577
+ text = read(file, 'utf8');
578
+ } catch (error) {
579
+ throw new Error(`${flag}: cannot read ${file}: ${error.message}`);
580
+ }
581
+ let parsed;
582
+ try {
583
+ parsed = JSON.parse(text);
584
+ } catch (error) {
585
+ throw new Error(`${flag}: ${file} is not valid JSON: ${error.message}`);
586
+ }
587
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
588
+ throw new Error(`${flag}: ${file} must contain a JSON object`);
589
+ }
590
+ return parsed;
591
+ }
592
+
593
+ /**
594
+ * The LEGACY builder key: `HMAC(RUNNER_KEY, 'builder')`, which is what the
595
+ * builder was recognised by until the 2026-09-01 audit (finding 3) and what an
596
+ * un-migrated builder app still holds as its own `RUNNER_KEY`. It kept the
597
+ * master off the build machine but not the build machine's authority off the
598
+ * pool — every pool machine holds the master and could mint this — so the
599
+ * builder now has its own `BUILDER_KEY`, derived from nothing, and the gateway
600
+ * REFUSES this value once that secret is set. Printed for debugging a 401 on a
601
+ * builder that has not been migrated; never a value to set `BUILDER_KEY` to
602
+ * (the gateway refuses that too, docs/DEPLOY.md §6).
603
+ *
604
+ * A re-derivation of the gateway's `deriveLegacyBuilderKey` (src/builder-key.ts),
605
+ * not an import: this package has no workspace dependency and the gateway is
606
+ * Worker code. The label, the newline separator and the base64url encoding are
607
+ * therefore duplicated by hand, and a drift between the two would not error
608
+ * anywhere — the builder would simply present a key the gateway answers 401 to.
609
+ * The shared test vector in tests/builder-key.test.ts and the gateway's
610
+ * builder-key-vector.test.ts is what turns that silence into a failing test.
611
+ *
612
+ * It lived in the gateway's `runtime/machine-key.ts` and minted one key per
613
+ * ISOLATED machine until that tier was retired (2026-09-11). `builder` is the
614
+ * one name it was ever derived for that was not a machine, which is why this
615
+ * function outlived the tier and still takes a name.
616
+ * @param {string} masterKey the gateway's RUNNER_KEY
617
+ * @param {string} machineName
618
+ */
619
+ export function deriveMachineKey(masterKey, machineName) {
620
+ return createHmac('sha256', masterKey)
621
+ .update(`mcp-host/runner-machine-key/v1\n${machineName}`)
622
+ .digest('base64url');
623
+ }
624
+
625
+ /**
626
+ * An uploaded bundle (the API's `bundleView`), as the lines a person reads
627
+ * after `bundles upload`, `register --mcpb <path>` and the wizard — ONE
628
+ * rendering so the three cannot describe the same row differently. Warnings
629
+ * are what the summary is FOR: a `user_config` key this host cannot deliver
630
+ * (referenced only in args) is exactly the thing to read before registering,
631
+ * and the bindings say which env vars the manifest expects.
632
+ *
633
+ * @param {{ sha256: string, name: string, version: string, serverType: string, manifestVersion: string,
634
+ * signature: string, size: number, warnings?: string[],
635
+ * userConfigBindings?: { key: string, envVar?: string, required: boolean, hasDefault: boolean, sensitive: boolean }[],
636
+ * existed?: boolean }} view
637
+ * @returns {string[]}
638
+ */
639
+ export function bundleSummaryLines(view) {
640
+ const lines = [
641
+ `Bundle: ${view.name}@${view.version} (${view.serverType}, manifest ${view.manifestVersion}, ${view.size} bytes)`,
642
+ ` sha256: ${view.sha256}${view.existed ? ' (already uploaded)' : ''}`,
643
+ // Presence only: the gateway records that a PKCS#7 block is appended and
644
+ // verifies nothing about it (docs/SECURITY.md), so this line must not
645
+ // read as "signed by someone".
646
+ ` signature: ${view.signature}${view.signature === 'present' ? ' (recorded, not verified)' : ''}`,
647
+ ];
648
+ for (const b of view.userConfigBindings ?? []) {
649
+ const via = b.envVar ? `env ${b.envVar}` : 'no env binding';
650
+ lines.push(` config ${b.key}: ${via}${b.required ? ', required' : ''}${b.hasDefault ? ', has default' : ''}${b.sensitive ? ', sensitive' : ''}`);
651
+ }
652
+ for (const w of view.warnings ?? []) lines.push(` warning: ${w}`);
653
+ return lines;
654
+ }