@chrischall/mcp-host-cli 0.0.0-stage → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/package.json +33 -4
- package/src/index.mjs +1333 -0
- package/src/lib.mjs +644 -0
- package/src/login.mjs +53 -0
- package/src/operations.mjs +579 -0
- package/src/prompt.mjs +126 -0
- package/src/seal.mjs +226 -0
- package/src/secrets.mjs +100 -0
- package/src/wizard.mjs +416 -0
package/src/lib.mjs
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
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 = `${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
|
+
}
|
|
526
|
+
return ` ${String(a.id).padEnd(14)} ${String(a.status).padEnd(9)} ${where} ${preview} ${when}`;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* `on`/`off` (or `true`/`false`, `yes`/`no`) as a boolean, for a switch flag
|
|
532
|
+
* that must be able to say false — a bare boolean flag can only say true.
|
|
533
|
+
*
|
|
534
|
+
* @param {string} value
|
|
535
|
+
* @param {string} flag for the refusal
|
|
536
|
+
*/
|
|
537
|
+
export function parseOnOff(value, flag) {
|
|
538
|
+
const v = value.trim().toLowerCase();
|
|
539
|
+
if (v === 'on' || v === 'true' || v === 'yes') return true;
|
|
540
|
+
if (v === 'off' || v === 'false' || v === 'no') return false;
|
|
541
|
+
throw new Error(`${flag} takes on or off, got ${JSON.stringify(value)}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* A nested JSON document named by a flag, e.g. `--auth-flow ./kia-flow.json`.
|
|
546
|
+
*
|
|
547
|
+
* A path rather than an inline string, because the documents this reads are
|
|
548
|
+
* nested: an authFlow is steps, arguments, `{from:}` references and prompts,
|
|
549
|
+
* all quotes and braces, and a shell is the worst possible place to assemble
|
|
550
|
+
* one. A file is also reviewable and diffable, which a login definition
|
|
551
|
+
* deserves — it decides what a person is asked for their password.
|
|
552
|
+
*
|
|
553
|
+
* The GRAMMAR is not checked here. The one schema that owns it lives in
|
|
554
|
+
* packages/core and runs on the write, so a copy of its rules in the CLI would
|
|
555
|
+
* be a second source of truth that drifts. What is checked is only what the
|
|
556
|
+
* server's error could not explain as well from here: which file, and that it
|
|
557
|
+
* held an object at all — a bare steps array (the `{ "steps": ... }` wrapper
|
|
558
|
+
* forgotten) is the near-miss worth naming plainly.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} file path to the document
|
|
561
|
+
* @param {string} flag the flag that named it, for the error message
|
|
562
|
+
* @param {(path: string, encoding: 'utf8') => string} read
|
|
563
|
+
*/
|
|
564
|
+
export function readJsonDocument(file, flag, read = readFileSync) {
|
|
565
|
+
let text;
|
|
566
|
+
try {
|
|
567
|
+
text = read(file, 'utf8');
|
|
568
|
+
} catch (error) {
|
|
569
|
+
throw new Error(`${flag}: cannot read ${file}: ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
let parsed;
|
|
572
|
+
try {
|
|
573
|
+
parsed = JSON.parse(text);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
throw new Error(`${flag}: ${file} is not valid JSON: ${error.message}`);
|
|
576
|
+
}
|
|
577
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
578
|
+
throw new Error(`${flag}: ${file} must contain a JSON object`);
|
|
579
|
+
}
|
|
580
|
+
return parsed;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The LEGACY builder key: `HMAC(RUNNER_KEY, 'builder')`, which is what the
|
|
585
|
+
* builder was recognised by until the 2026-09-01 audit (finding 3) and what an
|
|
586
|
+
* un-migrated builder app still holds as its own `RUNNER_KEY`. It kept the
|
|
587
|
+
* master off the build machine but not the build machine's authority off the
|
|
588
|
+
* pool — every pool machine holds the master and could mint this — so the
|
|
589
|
+
* builder now has its own `BUILDER_KEY`, derived from nothing, and the gateway
|
|
590
|
+
* REFUSES this value once that secret is set. Printed for debugging a 401 on a
|
|
591
|
+
* builder that has not been migrated; never a value to set `BUILDER_KEY` to
|
|
592
|
+
* (the gateway refuses that too, docs/DEPLOY.md §6).
|
|
593
|
+
*
|
|
594
|
+
* A re-derivation of the gateway's `deriveLegacyBuilderKey` (src/builder-key.ts),
|
|
595
|
+
* not an import: this package has no workspace dependency and the gateway is
|
|
596
|
+
* Worker code. The label, the newline separator and the base64url encoding are
|
|
597
|
+
* therefore duplicated by hand, and a drift between the two would not error
|
|
598
|
+
* anywhere — the builder would simply present a key the gateway answers 401 to.
|
|
599
|
+
* The shared test vector in tests/builder-key.test.ts and the gateway's
|
|
600
|
+
* builder-key-vector.test.ts is what turns that silence into a failing test.
|
|
601
|
+
*
|
|
602
|
+
* It lived in the gateway's `runtime/machine-key.ts` and minted one key per
|
|
603
|
+
* ISOLATED machine until that tier was retired (2026-09-11). `builder` is the
|
|
604
|
+
* one name it was ever derived for that was not a machine, which is why this
|
|
605
|
+
* function outlived the tier and still takes a name.
|
|
606
|
+
* @param {string} masterKey the gateway's RUNNER_KEY
|
|
607
|
+
* @param {string} machineName
|
|
608
|
+
*/
|
|
609
|
+
export function deriveMachineKey(masterKey, machineName) {
|
|
610
|
+
return createHmac('sha256', masterKey)
|
|
611
|
+
.update(`mcp-host/runner-machine-key/v1\n${machineName}`)
|
|
612
|
+
.digest('base64url');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* An uploaded bundle (the API's `bundleView`), as the lines a person reads
|
|
617
|
+
* after `bundles upload`, `register --mcpb <path>` and the wizard — ONE
|
|
618
|
+
* rendering so the three cannot describe the same row differently. Warnings
|
|
619
|
+
* are what the summary is FOR: a `user_config` key this host cannot deliver
|
|
620
|
+
* (referenced only in args) is exactly the thing to read before registering,
|
|
621
|
+
* and the bindings say which env vars the manifest expects.
|
|
622
|
+
*
|
|
623
|
+
* @param {{ sha256: string, name: string, version: string, serverType: string, manifestVersion: string,
|
|
624
|
+
* signature: string, size: number, warnings?: string[],
|
|
625
|
+
* userConfigBindings?: { key: string, envVar?: string, required: boolean, hasDefault: boolean, sensitive: boolean }[],
|
|
626
|
+
* existed?: boolean }} view
|
|
627
|
+
* @returns {string[]}
|
|
628
|
+
*/
|
|
629
|
+
export function bundleSummaryLines(view) {
|
|
630
|
+
const lines = [
|
|
631
|
+
`Bundle: ${view.name}@${view.version} (${view.serverType}, manifest ${view.manifestVersion}, ${view.size} bytes)`,
|
|
632
|
+
` sha256: ${view.sha256}${view.existed ? ' (already uploaded)' : ''}`,
|
|
633
|
+
// Presence only: the gateway records that a PKCS#7 block is appended and
|
|
634
|
+
// verifies nothing about it (docs/SECURITY.md), so this line must not
|
|
635
|
+
// read as "signed by someone".
|
|
636
|
+
` signature: ${view.signature}${view.signature === 'present' ? ' (recorded, not verified)' : ''}`,
|
|
637
|
+
];
|
|
638
|
+
for (const b of view.userConfigBindings ?? []) {
|
|
639
|
+
const via = b.envVar ? `env ${b.envVar}` : 'no env binding';
|
|
640
|
+
lines.push(` config ${b.key}: ${via}${b.required ? ', required' : ''}${b.hasDefault ? ', has default' : ''}${b.sensitive ? ', sensitive' : ''}`);
|
|
641
|
+
}
|
|
642
|
+
for (const w of view.warnings ?? []) lines.push(` warning: ${w}`);
|
|
643
|
+
return lines;
|
|
644
|
+
}
|