@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/wizard.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// The interactive register wizard: `mcp-host wizard`, or `mcp-host register`
|
|
2
|
+
// with no flags on a terminal.
|
|
3
|
+
//
|
|
4
|
+
// A conversation over the SAME control API the flags use — `api` is passed
|
|
5
|
+
// in (the (method, path, body) function index.mjs owns) so a test can
|
|
6
|
+
// record every call, and the prompter is passed in so a test can script the
|
|
7
|
+
// answers. Nothing here resolves a source or validates a slug beyond what it
|
|
8
|
+
// needs to ask the next question: the preview endpoint is the resolver and
|
|
9
|
+
// the create is the validator, and a copy of either rule would drift.
|
|
10
|
+
//
|
|
11
|
+
// Every line it prints goes to `stderr`, like the prompts: stdout stays the
|
|
12
|
+
// JSON of the registration that was created, exactly as `register` prints.
|
|
13
|
+
|
|
14
|
+
import { DEP_GRAMMAR, bundleSummaryLines, parseGithubSpec, parseMcpbSpec, parseNpmSpec, suggestSlug } from './lib.mjs';
|
|
15
|
+
import { storeSecretSealed } from './secrets.mjs';
|
|
16
|
+
|
|
17
|
+
/** Secret values never pass through this string; env values and names do. */
|
|
18
|
+
function shellQuote(value) {
|
|
19
|
+
return /^[A-Za-z0-9_@%+=:,.#/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A source back to the `--npm`/`--github`/`--mcpb` spelling `register`
|
|
24
|
+
* accepts. An mcpb source in the BODY is already by hash or by URL — a path
|
|
25
|
+
* was uploaded before the body existed — so the flag it re-spells names the
|
|
26
|
+
* hash, which is the form that works from any machine.
|
|
27
|
+
*/
|
|
28
|
+
function sourceSpec(source) {
|
|
29
|
+
if (source.type === 'npm') return `${source.pkg}${source.version ? `@${source.version}` : ''}`;
|
|
30
|
+
if (source.type === 'mcpb') return source.url ?? `sha256:${source.sha256}`;
|
|
31
|
+
return `${source.repo}${source.ref ? `#${source.ref}` : source.commit ? `#${source.commit}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A dependency input back to the one-line `--dep` grammar. */
|
|
35
|
+
function depSpec(dep) {
|
|
36
|
+
if (dep.kind === 'npm') return `npm:${dep.pkg}${dep.version ? `@${dep.version}` : ''}`;
|
|
37
|
+
return `github:${dep.repo}@${dep.tag}:${dep.asset}#${dep.bin.join(',')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The flags that would have produced this body, for the epilogue. Teaching
|
|
42
|
+
* the flags is the point: the wizard is for the first registration, and the
|
|
43
|
+
* second one is usually a script. Secret VALUES cannot appear — the body
|
|
44
|
+
* carries references by name, and the value was sent on its own PUT.
|
|
45
|
+
*/
|
|
46
|
+
export function equivalentCommand(body) {
|
|
47
|
+
const parts = ['mcp-host register', '--slug', shellQuote(body.slug)];
|
|
48
|
+
if (body.account !== undefined) parts.push('--account', shellQuote(body.account));
|
|
49
|
+
if (body.source.type === 'npm') parts.push('--npm', shellQuote(sourceSpec(body.source)));
|
|
50
|
+
else if (body.source.type === 'mcpb') parts.push('--mcpb', shellQuote(sourceSpec(body.source)));
|
|
51
|
+
else {
|
|
52
|
+
parts.push('--github', shellQuote(sourceSpec(body.source)));
|
|
53
|
+
if (body.source.subdir) parts.push('--subdir', shellQuote(body.source.subdir));
|
|
54
|
+
if (body.source.build) parts.push('--build', shellQuote(body.source.build));
|
|
55
|
+
}
|
|
56
|
+
if (body.displayName) parts.push('--name', shellQuote(body.displayName));
|
|
57
|
+
if (body.command?.bin) parts.push('--bin', shellQuote(body.command.bin));
|
|
58
|
+
for (const [name, value] of Object.entries(body.env ?? {})) {
|
|
59
|
+
if (typeof value === 'string') parts.push('--env', shellQuote(`${name}=${value}`));
|
|
60
|
+
else parts.push('--secret-env', shellQuote(`${name}=${value.secret}`));
|
|
61
|
+
}
|
|
62
|
+
for (const dep of body.dependencies ?? []) {
|
|
63
|
+
parts.push('--dep', shellQuote(depSpec(dep)));
|
|
64
|
+
if (dep.sha256) parts.push('--dep-sha256', shellQuote(`${dep.asset}=${dep.sha256}`));
|
|
65
|
+
}
|
|
66
|
+
if (body.updatePolicy === 'follow') parts.push('--follow');
|
|
67
|
+
if (body.dataDir === false) parts.push('--no-data-dir');
|
|
68
|
+
if (body.perUserChild) parts.push('--per-user-child');
|
|
69
|
+
if (body.bridge) parts.push('--bridge');
|
|
70
|
+
return parts.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeSource(source) {
|
|
74
|
+
if (source.type === 'npm') return `${source.pkg}@${source.version}`;
|
|
75
|
+
if (source.type === 'mcpb') return `${source.name}@${source.version} (mcpb ${source.serverType}, sha256 ${source.sha256})`;
|
|
76
|
+
return `${source.repo}@${source.commit}${source.subdir ? ` (${source.subdir})` : ''}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {import('./prompt.mjs').Prompter} prompter
|
|
81
|
+
* @param {(method: string, path: string, body?: unknown) => Promise<any>} api
|
|
82
|
+
* @param {{ stderr?: { write(s: string): unknown }, uploadBundle?: (file: string) => Promise<any> }} [opts]
|
|
83
|
+
* `uploadBundle` is index.mjs's raw `PUT /api/v1/bundles` (the one non-JSON
|
|
84
|
+
* request), passed in like `api` so a test can hand over a recorder.
|
|
85
|
+
* @returns {Promise<{ registration: any, body: object, secretsStored: string[] }>}
|
|
86
|
+
*/
|
|
87
|
+
export async function runWizard(prompter, api, { stderr = process.stderr, uploadBundle } = {}) {
|
|
88
|
+
const say = (s = '') => stderr.write(`${s}\n`);
|
|
89
|
+
|
|
90
|
+
// --- source, previewed before anything else is asked -------------------
|
|
91
|
+
// The preview is the first call because everything after it (the slug
|
|
92
|
+
// suggestion, the env rows, the bins) is read off its answer, and a source
|
|
93
|
+
// that does not resolve should fail here, before a single other question.
|
|
94
|
+
let source;
|
|
95
|
+
let preview;
|
|
96
|
+
for (;;) {
|
|
97
|
+
// APPENDED, never inserted: the menu is answered by position in every
|
|
98
|
+
// scripted run (wizard.test.ts), and by habit at a terminal.
|
|
99
|
+
const kind = await prompter.choose('Where does the MCP come from?', [
|
|
100
|
+
{ value: 'npm', label: 'an npm package' },
|
|
101
|
+
{ value: 'github', label: 'a GitHub repository' },
|
|
102
|
+
{ value: 'mcpb', label: 'an MCPB bundle (.mcpb file or URL)' },
|
|
103
|
+
]);
|
|
104
|
+
try {
|
|
105
|
+
if (kind === 'npm') {
|
|
106
|
+
source = parseNpmSpec(await prompter.ask('Package (name[@version|dist-tag])'));
|
|
107
|
+
} else if (kind === 'mcpb') {
|
|
108
|
+
// A path is uploaded NOW, before the preview, because the preview
|
|
109
|
+
// can only name a bundle the gateway holds; the summary it answers
|
|
110
|
+
// (type, signature presence, the config this host cannot deliver) is
|
|
111
|
+
// printed before anything else is asked. A refused upload re-asks
|
|
112
|
+
// the source like a refused preview does. The body then names the
|
|
113
|
+
// HASH — the URL form stays a URL, the gateway fetches it.
|
|
114
|
+
const spec = parseMcpbSpec(await prompter.ask('Bundle (.mcpb path, https URL, or sha256:<hex> of one already uploaded)'));
|
|
115
|
+
if (spec.kind === 'url') source = { type: 'mcpb', url: spec.url };
|
|
116
|
+
else if (spec.kind === 'sha256') source = { type: 'mcpb', sha256: spec.sha256 };
|
|
117
|
+
else {
|
|
118
|
+
if (!uploadBundle) throw new Error('this wizard was started without an uploader; pass --mcpb to `register` instead');
|
|
119
|
+
say('Uploading…');
|
|
120
|
+
const view = await uploadBundle(spec.path);
|
|
121
|
+
for (const line of bundleSummaryLines(view)) say(line);
|
|
122
|
+
source = { type: 'mcpb', sha256: view.sha256 };
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
source = parseGithubSpec(await prompter.ask('Repository (owner/repo[#ref])'));
|
|
126
|
+
const subdir = await prompter.ask('Subdirectory (monorepos; blank for the root)', { default: '' });
|
|
127
|
+
const build = await prompter.ask('Build command (blank for none; installs never run scripts)', { default: '' });
|
|
128
|
+
if (subdir) source.subdir = subdir;
|
|
129
|
+
if (build) source.build = build;
|
|
130
|
+
}
|
|
131
|
+
say('Resolving…');
|
|
132
|
+
preview = await api('POST', '/api/v1/registrations/preview', { source });
|
|
133
|
+
break;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
say(` ${error.message}`);
|
|
136
|
+
say(' Try again.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
printPreview(say, preview);
|
|
140
|
+
|
|
141
|
+
// --- identity -----------------------------------------------------------
|
|
142
|
+
// A bundle's input names no package; the manifest's name is on the pin.
|
|
143
|
+
const sourceName = source.type === 'npm' ? source.pkg : source.type === 'mcpb' ? preview.source.name : source.repo;
|
|
144
|
+
let slug;
|
|
145
|
+
for (;;) {
|
|
146
|
+
slug = await prompter.ask('Slug (the URL path: /c/<account>/<slug>/mcp)', { default: suggestSlug(sourceName) });
|
|
147
|
+
if (slug) break;
|
|
148
|
+
say(' a slug is required');
|
|
149
|
+
}
|
|
150
|
+
const displayName = await prompter.ask('Display name', { default: preview.package?.name ?? slug });
|
|
151
|
+
|
|
152
|
+
// --- env ----------------------------------------------------------------
|
|
153
|
+
/** @type {Record<string, string | {secret: string}>} */
|
|
154
|
+
const env = {};
|
|
155
|
+
/** @type {string[]} */
|
|
156
|
+
const secretsStored = [];
|
|
157
|
+
/** @type {string[] | null} — fetched once, the first time a row asks */
|
|
158
|
+
let existingSecrets = null;
|
|
159
|
+
const secretNames = async () => {
|
|
160
|
+
if (existingSecrets === null) {
|
|
161
|
+
const listed = await api('GET', '/api/v1/secrets');
|
|
162
|
+
existingSecrets = listed.secrets.map((s) => s.name);
|
|
163
|
+
}
|
|
164
|
+
return existingSecrets;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One env row. Returns when the row is settled: bound, or skipped when
|
|
169
|
+
* that is allowed. A required row re-asks, because the preview said the
|
|
170
|
+
* MCP will not start without it and a create that omits it is a
|
|
171
|
+
* registration that fails at its first spawn.
|
|
172
|
+
*/
|
|
173
|
+
async function envRow(name, { required, secret: secretShaped, help }) {
|
|
174
|
+
const tag = required ? 'required' : 'optional';
|
|
175
|
+
for (;;) {
|
|
176
|
+
say();
|
|
177
|
+
say(`${name} (${tag}${secretShaped ? ', a secret' : ''})${help ? ` — ${help}` : ''}`);
|
|
178
|
+
const how = await prompter.choose(`How should ${name} be set?`, [
|
|
179
|
+
{ value: 'literal', label: 'a literal value (stored in plaintext, shown on every GET)' },
|
|
180
|
+
{ value: 'secret', label: 'an existing secret' },
|
|
181
|
+
{ value: 'new', label: 'a new secret (entered now, without echo)' },
|
|
182
|
+
{ value: 'skip', label: required ? 'skip (not allowed: required)' : 'skip' },
|
|
183
|
+
]);
|
|
184
|
+
if (how === 'skip') {
|
|
185
|
+
if (!required) return;
|
|
186
|
+
say(` ${name} is required`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (how === 'literal') {
|
|
190
|
+
const value = await prompter.ask(`Value for ${name}`);
|
|
191
|
+
if (value === '' && required) {
|
|
192
|
+
say(` ${name} is required`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (secretShaped) say(' note: the metadata marks this a secret; a literal is stored in plaintext');
|
|
196
|
+
env[name] = value;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (how === 'secret') {
|
|
200
|
+
const names = await secretNames();
|
|
201
|
+
if (names.length === 0) {
|
|
202
|
+
say(' no secrets stored yet — choose "a new secret"');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const picked = await prompter.choose('Which secret?', names.map((n) => ({ value: n })));
|
|
206
|
+
env[name] = { secret: picked };
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// 'new': stored IMMEDIATELY, before the create, so the reference the
|
|
210
|
+
// body carries names a secret that exists. The value goes on its own
|
|
211
|
+
// PUT and nowhere else — not the body, not the epilogue.
|
|
212
|
+
const secretName = await prompter.ask('Secret name', { default: name });
|
|
213
|
+
const value = await prompter.secret(`Value for ${secretName}`);
|
|
214
|
+
if (value === '') {
|
|
215
|
+
say(' an empty secret is not stored');
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Sealed to the account key on the way out — the SAME path `secret
|
|
219
|
+
// set` takes (secrets.mjs), so the wizard cannot store a value the
|
|
220
|
+
// gateway can read. A refused PUT re-asks the row rather than ending
|
|
221
|
+
// the wizard: the value never reached the store, so nothing is
|
|
222
|
+
// half-done, and the person can pick another way (an existing secret,
|
|
223
|
+
// a literal, skip).
|
|
224
|
+
try {
|
|
225
|
+
await storeSecretSealed(api, secretName, value, { stderr });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
say(` ${error.message}`);
|
|
228
|
+
say(` ${secretName} was not stored.`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
secretsStored.push(secretName);
|
|
232
|
+
if (existingSecrets && !existingSecrets.includes(secretName)) existingSecrets.push(secretName);
|
|
233
|
+
env[name] = { secret: secretName };
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (preview.env.length > 0) {
|
|
239
|
+
say();
|
|
240
|
+
say(`Environment proposed by the package's ${preview.envSource ?? 'metadata'}:`);
|
|
241
|
+
}
|
|
242
|
+
for (const row of preview.env) await envRow(row.name, row);
|
|
243
|
+
while (await prompter.confirm('Add another environment variable?', false)) {
|
|
244
|
+
const name = await prompter.ask('Variable name');
|
|
245
|
+
if (!name) continue;
|
|
246
|
+
await envRow(name, { required: false, secret: false });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- dependencies -------------------------------------------------------
|
|
250
|
+
// Not asked for a bundle: the API refuses dependencies on one ("ships its
|
|
251
|
+
// own tree"), and asking a question whose every answer is refused is
|
|
252
|
+
// worse than saying so once.
|
|
253
|
+
const dependencies = [];
|
|
254
|
+
const isBundle = source.type === 'mcpb';
|
|
255
|
+
say();
|
|
256
|
+
if (isBundle) say('A bundle ships its own tree: no dependencies, and no version channel to follow.');
|
|
257
|
+
else {
|
|
258
|
+
say('Dependencies are tools the MCP shells out to that are not in its npm tree');
|
|
259
|
+
say(`(the flag form: ${DEP_GRAMMAR}).`);
|
|
260
|
+
}
|
|
261
|
+
while (!isBundle && (await prompter.confirm('Add a dependency?', false))) {
|
|
262
|
+
const kind = await prompter.choose('Dependency kind', [
|
|
263
|
+
{ value: 'github-release', label: 'a GitHub release asset (a binary on the PATH)' },
|
|
264
|
+
{ value: 'npm', label: 'an npm package (its bins on the PATH)' },
|
|
265
|
+
]);
|
|
266
|
+
if (kind === 'npm') {
|
|
267
|
+
const { pkg, version } = parseNpmSpec(await prompter.ask('Package (name[@version|dist-tag])'));
|
|
268
|
+
dependencies.push({ kind: 'npm', pkg, ...(version ? { version } : {}) });
|
|
269
|
+
} else {
|
|
270
|
+
const repo = await prompter.ask('Repository (owner/repo)');
|
|
271
|
+
const tag = await prompter.ask('Release tag', { default: 'latest' });
|
|
272
|
+
const asset = await prompter.ask('Asset name (exact, or a glob with * matching exactly one)');
|
|
273
|
+
const bins = (await prompter.ask('Executables to extract (comma-separated)'))
|
|
274
|
+
.split(',')
|
|
275
|
+
.map((b) => b.trim())
|
|
276
|
+
.filter(Boolean);
|
|
277
|
+
const sha256 = await prompter.ask('sha256 of the asset (only when GitHub reports no digest; blank otherwise)', {
|
|
278
|
+
default: '',
|
|
279
|
+
});
|
|
280
|
+
dependencies.push({ kind: 'github-release', repo, tag, asset, bin: bins, ...(sha256 ? { sha256 } : {}) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (dependencies.length > 0 && (await prompter.confirm('Re-resolve the preview with these dependencies?', false))) {
|
|
284
|
+
try {
|
|
285
|
+
preview = await api('POST', '/api/v1/registrations/preview', { source, dependencies });
|
|
286
|
+
printPreview(say, preview);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
say(` ${error.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- options ------------------------------------------------------------
|
|
293
|
+
// No runtime question: there is one tier since the isolated one was retired
|
|
294
|
+
// (2026-09-11), and no egress question either — `requireEgressPolicy` asks
|
|
295
|
+
// it of a TENANT, and this CLI authenticates with MCP_HOST_ADMIN_TOKEN, so
|
|
296
|
+
// its caller is always the operator (index.mjs `buildRegisterBody` says the
|
|
297
|
+
// whole of it). The portal is where a tenant is asked.
|
|
298
|
+
//
|
|
299
|
+
// Same for `follow`: the API refuses it on a bundle (no channel to follow).
|
|
300
|
+
const follow = isBundle ? false : await prompter.confirm('Follow the latest version (the daily cron moves the pin)?', false);
|
|
301
|
+
// Default YES, because the server's default is yes: since
|
|
302
|
+
// docs/GLOBAL-REGISTRATIONS.md §5 a registration that says nothing GETS a
|
|
303
|
+
// persistent HOME, and a question whose default contradicted the server's
|
|
304
|
+
// would hand somebody the opposite of what they read. The ANSWER is sent
|
|
305
|
+
// only when it is "no" — see the body below.
|
|
306
|
+
const dataDir = await prompter.confirm('Persistent data directory (HOME on the runner volume)?', true);
|
|
307
|
+
const perUserChild = await prompter.confirm('One child per connected user (per-user credentials)?', false);
|
|
308
|
+
// Asked AFTER dataDir on purpose: a bridged MCP's fetchproxy identity lives
|
|
309
|
+
// under its HOME, so without persistence the pair code changes on every cold
|
|
310
|
+
// start and the extension has to be re-paired each time
|
|
311
|
+
// (docs/BROWSER-BRIDGE.md). The prompt says so rather than letting someone
|
|
312
|
+
// discover it later, and the default follows what was just answered.
|
|
313
|
+
const bridge = await prompter.confirm(
|
|
314
|
+
dataDir
|
|
315
|
+
? 'Route this MCP\'s fetches through your browser (the fetchproxy bridge)?'
|
|
316
|
+
: 'Route this MCP\'s fetches through your browser (the fetchproxy bridge)? Without a persistent data directory its pair code changes on every cold start',
|
|
317
|
+
false,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// The body `register` would have built from the equivalent flags: defaults
|
|
321
|
+
// omitted, `dependencies` only when there are any. No `account` either — the
|
|
322
|
+
// wizard never asks for one, and an absent account is the caller's own.
|
|
323
|
+
const body = {
|
|
324
|
+
slug,
|
|
325
|
+
displayName,
|
|
326
|
+
source,
|
|
327
|
+
env,
|
|
328
|
+
enabledTools: null,
|
|
329
|
+
...(follow ? { updatePolicy: 'follow' } : {}),
|
|
330
|
+
// Sent only when DECLINED. Omitted means "the server decides", and it
|
|
331
|
+
// decides yes; a `false` is the one answer the body has to carry, or the
|
|
332
|
+
// question would be decorative.
|
|
333
|
+
...(dataDir ? {} : { dataDir: false }),
|
|
334
|
+
...(perUserChild ? { perUserChild: true } : {}),
|
|
335
|
+
...(bridge ? { bridge: true } : {}),
|
|
336
|
+
...(dependencies.length > 0 ? { dependencies } : {}),
|
|
337
|
+
};
|
|
338
|
+
if (preview.package && preview.package.bins.length > 1) {
|
|
339
|
+
const bin = await prompter.choose('The package has several bins; which one is the MCP?', preview.package.bins.map((b) => ({ value: b })));
|
|
340
|
+
body.command = { bin };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- summary + confirm --------------------------------------------------
|
|
344
|
+
say();
|
|
345
|
+
say('Summary');
|
|
346
|
+
say(` slug: ${slug}`);
|
|
347
|
+
say(` name: ${displayName}`);
|
|
348
|
+
say(` source: ${describeSource(preview.source)}`);
|
|
349
|
+
for (const [name, value] of Object.entries(env)) {
|
|
350
|
+
say(` env: ${name} = ${typeof value === 'string' ? value : `{secret ${value.secret}}`}`);
|
|
351
|
+
}
|
|
352
|
+
for (const dep of dependencies) say(` dep: ${depSpec(dep)}`);
|
|
353
|
+
if (follow) say(' follow latest: yes');
|
|
354
|
+
say(` data dir: ${dataDir ? 'yes' : 'no'}`);
|
|
355
|
+
if (perUserChild) say(' per-user child: yes');
|
|
356
|
+
if (bridge) say(` browser bridge: yes${dataDir ? '' : ' (no data dir — the pair code changes on every cold start)'}`);
|
|
357
|
+
say();
|
|
358
|
+
say('Equivalent command:');
|
|
359
|
+
say(` ${equivalentCommand(body)}`);
|
|
360
|
+
say();
|
|
361
|
+
|
|
362
|
+
// Said on every exit that is not a created registration — a declined
|
|
363
|
+
// confirm AND a refused create — because the secrets were stored when they
|
|
364
|
+
// were entered, and a person who was never told would not know to `rm`.
|
|
365
|
+
const sayStoredSecrets = () => {
|
|
366
|
+
if (secretsStored.length === 0) return;
|
|
367
|
+
say(`The secret${secretsStored.length > 1 ? 's' : ''} already stored stay stored: ${secretsStored.join(', ')}`);
|
|
368
|
+
say('(remove with `mcp-host secret rm <NAME>`).');
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (!(await prompter.confirm('Register it?', true))) {
|
|
372
|
+
say('Not registered.');
|
|
373
|
+
sayStoredSecrets();
|
|
374
|
+
return { registration: null, body, secretsStored };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let registration;
|
|
378
|
+
try {
|
|
379
|
+
registration = await api('POST', '/api/v1/registrations', body);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// Not retried: a refused create is usually the slug or the source, and
|
|
382
|
+
// the answers that led here are the equivalent command printed above —
|
|
383
|
+
// edit it and run that. Rethrown so the CLI exits 1 as `register` would.
|
|
384
|
+
say(` ${error.message}`);
|
|
385
|
+
say('Not registered.');
|
|
386
|
+
sayStoredSecrets();
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
say();
|
|
390
|
+
say('Add to claude.ai: Settings -> Connectors -> Add custom connector ->');
|
|
391
|
+
say(` URL: ${registration.shortConnectUrl ?? registration.connectUrl}`);
|
|
392
|
+
say(' Sign in there and approve the connection — no key to paste.');
|
|
393
|
+
say();
|
|
394
|
+
say('Next time, the same registration without the questions:');
|
|
395
|
+
say(` ${equivalentCommand(body)}`);
|
|
396
|
+
return { registration, body, secretsStored };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function printPreview(say, preview) {
|
|
400
|
+
say();
|
|
401
|
+
say(`Pinned: ${describeSource(preview.source)}`);
|
|
402
|
+
if (preview.source.type === 'npm') say(` integrity: ${preview.source.integrity ?? '(none recorded — installs unverified)'}`);
|
|
403
|
+
if (preview.package) {
|
|
404
|
+
if (preview.package.description) say(` ${preview.package.description}`);
|
|
405
|
+
say(` bins: ${preview.package.bins.length > 0 ? preview.package.bins.join(', ') : '(none)'}`);
|
|
406
|
+
}
|
|
407
|
+
for (const dep of preview.dependencies ?? []) {
|
|
408
|
+
say(
|
|
409
|
+
dep.kind === 'npm'
|
|
410
|
+
? ` dependency: npm ${dep.pkg}@${dep.version}`
|
|
411
|
+
: ` dependency: ${dep.repo}@${dep.tag} ${dep.asset}${dep.sha256 ? ` sha256 ${dep.sha256}` : ''}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
for (const note of preview.notes ?? []) say(` note: ${note}`);
|
|
415
|
+
for (const skipped of preview.skipped ?? []) say(` skipped ${skipped.name}: ${skipped.reason}`);
|
|
416
|
+
}
|