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