@hue-run/sdk 0.11.2 → 0.11.4
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/CLI.md +62 -25
- package/README.md +10 -7
- package/dist/cli/eval.js +16 -10
- package/dist/cli/listen.js +21 -2
- package/dist/cli/login.js +2 -1
- package/dist/cli/mcp.d.ts +5 -4
- package/dist/cli/mcp.js +480 -48
- package/dist/environment/world.d.ts +4 -2
- package/dist/environment/world.js +13 -4
- package/dist/evals/checkpoint.d.ts +5 -0
- package/dist/evals/checkpoint.js +17 -1
- package/dist/evals/local-worker.js +9 -5
- package/dist/evals/simulation.js +7 -5
- package/dist/provider-tools.d.ts +3 -1
- package/dist/provider-tools.js +4 -2
- package/dist/tool-definitions.d.ts +14 -3
- package/dist/tool-definitions.js +635 -53
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/mcp.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { access,
|
|
5
|
-
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { access, lstat, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isLoopbackHost } from "../config.js";
|
|
8
|
+
import { isCredentialKey, scrubCredentialText } from "../tool-definitions.js";
|
|
8
9
|
export const DEFAULT_MCP_URL = "https://mcp.hue.run/mcp";
|
|
9
10
|
const SERVER_NAME = "hue";
|
|
10
11
|
const ENV_VAR = "HUE_MCP_KEY";
|
|
@@ -81,18 +82,21 @@ export const MCP_USAGE = `Usage: hue mcp install --client <claude-code|codex|con
|
|
|
81
82
|
[--toolsets NAMES] [--url URL]
|
|
82
83
|
[--scope project|user] [--dry-run] [--print]
|
|
83
84
|
|
|
84
|
-
Configure a coding agent to use the Hue MCP server. A
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
Configure a coding agent to use the Hue MCP server. A sign-in configuration holds only the
|
|
86
|
+
URL: the client opens Hue in a browser, where you approve access to the projects of one
|
|
87
|
+
organization. A key configuration references the ${ENV_VAR} environment variable; a key value
|
|
88
|
+
is never written.
|
|
88
89
|
|
|
89
90
|
Options:
|
|
90
91
|
--client NAME Coding agent to configure (required)
|
|
91
|
-
--auth MODE
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
--auth MODE oauth: URL only, sign in with Hue in the client (claude-code, codex and
|
|
93
|
+
conductor; their default)
|
|
94
|
+
key: reference ${ENV_VAR} (the default for the other clients and with
|
|
95
|
+
--read-only). Without --auth, claude-code and codex keep the choice of
|
|
96
|
+
an existing entry, and use a key for a new one when ${ENV_VAR} is set
|
|
97
|
+
--read-only Add ?read_only=true to a key configuration's URL so write tools are
|
|
98
|
+
hidden; selects a key without --auth. A sign-in connection has Read and
|
|
99
|
+
write access; use a Read key for read-only
|
|
96
100
|
--project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,
|
|
97
101
|
and the server is named hue-<value> so it can coexist with an unpinned hue
|
|
98
102
|
--toolsets NAMES
|
|
@@ -113,7 +117,9 @@ Options:
|
|
|
113
117
|
Files: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the
|
|
114
118
|
current directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the
|
|
115
119
|
snippet for its user configuration file. conductor registers the server for its Claude Code
|
|
116
|
-
(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH
|
|
120
|
+
(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.
|
|
121
|
+
codex mcp add signs in at once and waits up to 5 minutes for the browser, so without --auth
|
|
122
|
+
and outside a terminal the command prints it instead of running it.`;
|
|
117
123
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
118
124
|
/**
|
|
119
125
|
* A URL as a POSIX shell word for printed commands: `?` is a zsh glob and `&` ends a command, so a
|
|
@@ -280,6 +286,10 @@ export function readOnlyMcpUrl(url) {
|
|
|
280
286
|
parsed.searchParams.set("read_only", "true");
|
|
281
287
|
return parsed.href;
|
|
282
288
|
}
|
|
289
|
+
/** Whether Hue treats the URL as read-only: it honors only `read_only=true` or `read_only=1`. */
|
|
290
|
+
function isReadOnlyUrl(url) {
|
|
291
|
+
return ["true", "1"].includes(new URL(url).searchParams.get("read_only") ?? "");
|
|
292
|
+
}
|
|
283
293
|
class ConfigError extends Error {
|
|
284
294
|
constructor(message) {
|
|
285
295
|
super(message);
|
|
@@ -306,6 +316,7 @@ function mergeVscodeInput(root, input, display) {
|
|
|
306
316
|
const others = (inputs ?? []).filter((item) => !(isRecord(item) && item.id === INPUT_ID));
|
|
307
317
|
return { ...root, inputs: [...others, input] };
|
|
308
318
|
}
|
|
319
|
+
/** The parsed file, if any, and the file it was read from, so the write can replace that one. */
|
|
309
320
|
async function readJsonConfig(path, display) {
|
|
310
321
|
let info;
|
|
311
322
|
try {
|
|
@@ -313,7 +324,7 @@ async function readJsonConfig(path, display) {
|
|
|
313
324
|
}
|
|
314
325
|
catch (error) {
|
|
315
326
|
if (error.code === "ENOENT")
|
|
316
|
-
return undefined;
|
|
327
|
+
return { config: undefined };
|
|
317
328
|
throw new ConfigError(`Cannot read ${display}: ${error.message}`);
|
|
318
329
|
}
|
|
319
330
|
if (info.isSymbolicLink())
|
|
@@ -324,40 +335,104 @@ async function readJsonConfig(path, display) {
|
|
|
324
335
|
throw new ConfigError(`Refusing to use ${display}: it is larger than 1 MiB.`);
|
|
325
336
|
const text = await readFile(path, "utf8");
|
|
326
337
|
if (!text.trim())
|
|
327
|
-
return undefined;
|
|
338
|
+
return { config: undefined, file: info };
|
|
328
339
|
try {
|
|
329
|
-
return JSON.parse(text);
|
|
340
|
+
return { config: JSON.parse(text), file: info };
|
|
330
341
|
}
|
|
331
342
|
catch {
|
|
332
343
|
throw new ConfigError(`${display} is not valid JSON (comments are not supported); fix it or add the snippet by hand.`);
|
|
333
344
|
}
|
|
334
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Refuses a symbolic link, or anything but a directory, between the working directory and the file
|
|
348
|
+
* (`.cursor` or `.vscode`), as the file itself is refused. The working directory and its ancestors
|
|
349
|
+
* are not checked: on macOS `/tmp` and `/var` are links, and a home directory can be one. This runs
|
|
350
|
+
* before the file is read and again before the rename. Node has no `openat`, so it cannot pin the
|
|
351
|
+
* directory against an account that can write the project and swaps a link in after the last check.
|
|
352
|
+
*/
|
|
353
|
+
async function rejectLinkedParents(cwd, path, display) {
|
|
354
|
+
let current = cwd;
|
|
355
|
+
for (const part of relative(cwd, dirname(path)).split(sep).filter(Boolean)) {
|
|
356
|
+
current = join(current, part);
|
|
357
|
+
let info;
|
|
358
|
+
try {
|
|
359
|
+
info = await lstat(current);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (error.code === "ENOENT")
|
|
363
|
+
return;
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
const shown = displayPath(cwd, current);
|
|
367
|
+
if (info.isSymbolicLink())
|
|
368
|
+
throw new ConfigError(`Refusing to use ${display}: ${shown} is a symbolic link.`);
|
|
369
|
+
if (!info.isDirectory())
|
|
370
|
+
throw new ConfigError(`Refusing to use ${display}: ${shown} is not a directory.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** The file's status, or undefined when there is none; a symbolic link is refused. */
|
|
335
374
|
async function rejectSymlink(path, display) {
|
|
375
|
+
let info;
|
|
336
376
|
try {
|
|
337
|
-
|
|
338
|
-
throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
|
|
377
|
+
info = await lstat(path);
|
|
339
378
|
}
|
|
340
379
|
catch (error) {
|
|
341
380
|
if (error.code === "ENOENT")
|
|
342
|
-
return;
|
|
381
|
+
return undefined;
|
|
343
382
|
throw error;
|
|
344
383
|
}
|
|
384
|
+
if (info.isSymbolicLink())
|
|
385
|
+
throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
|
|
386
|
+
return info;
|
|
345
387
|
}
|
|
346
|
-
/**
|
|
347
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Atomic write: temporary file, fsync, rename. A file it replaces keeps its permission bits, so one
|
|
390
|
+
* kept at 0600 because it holds other servers' tokens is never widened, except that other accounts
|
|
391
|
+
* lose write access; it keeps its group (or, when the group cannot be kept, its group and other
|
|
392
|
+
* accounts get only the access both had), and its owner when root runs the command. A new file is
|
|
393
|
+
* created owner-only, 0600 narrowed by the umask: people and clients add literal tokens to these
|
|
394
|
+
* files.
|
|
395
|
+
*/
|
|
396
|
+
async function writeConfigFile(cwd, path, text, display, read) {
|
|
348
397
|
await mkdir(dirname(path), { recursive: true });
|
|
349
|
-
await rejectSymlink(path, display);
|
|
398
|
+
const existing = await rejectSymlink(path, display);
|
|
399
|
+
// The file replaced must be the one read: the content merged it, and its mode and group are kept.
|
|
400
|
+
// Another file in its place, such as one in a directory swapped in meanwhile, is refused.
|
|
401
|
+
if (existing?.dev !== read?.dev || existing?.ino !== read?.ino)
|
|
402
|
+
throw new ConfigError(`Refusing to write ${display}: it changed while the command ran.`);
|
|
350
403
|
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
351
404
|
try {
|
|
352
|
-
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
405
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
353
406
|
try {
|
|
354
407
|
await handle.writeFile(text, "utf8");
|
|
408
|
+
if (existing) {
|
|
409
|
+
// Other accounts never keep write access: they could add a server command to run.
|
|
410
|
+
let mode = existing.mode & 0o775;
|
|
411
|
+
// The new file has the process's group (the directory's on macOS), which may differ. When
|
|
412
|
+
// the group cannot be kept, its members and everyone else get only what both had.
|
|
413
|
+
const created = await handle.stat();
|
|
414
|
+
if (created.gid !== existing.gid) {
|
|
415
|
+
try {
|
|
416
|
+
await handle.chown(-1, existing.gid);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
const shared = (mode >> 3) & mode & 0o007;
|
|
420
|
+
mode = (mode & 0o700) | (shared << 3) | shared;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
await handle.chmod(mode);
|
|
424
|
+
// Root also gives the file back to its owner, last: changing the mode of another account's
|
|
425
|
+
// file would need CAP_FOWNER. If it cannot, the write fails and the old file stays in place,
|
|
426
|
+
// rather than leaving one its owner cannot read.
|
|
427
|
+
if (process.geteuid?.() === 0 && created.uid !== existing.uid)
|
|
428
|
+
await handle.chown(existing.uid, -1);
|
|
429
|
+
}
|
|
355
430
|
await handle.sync();
|
|
356
431
|
}
|
|
357
432
|
finally {
|
|
358
433
|
await handle.close();
|
|
359
434
|
}
|
|
360
|
-
await
|
|
435
|
+
await rejectLinkedParents(cwd, path, display);
|
|
361
436
|
await rejectSymlink(path, display);
|
|
362
437
|
await rename(temporary, path);
|
|
363
438
|
}
|
|
@@ -366,6 +441,149 @@ async function writeConfigFile(path, text, display) {
|
|
|
366
441
|
throw error;
|
|
367
442
|
}
|
|
368
443
|
}
|
|
444
|
+
const REDACTED = "[redacted]";
|
|
445
|
+
/**
|
|
446
|
+
* Only references a client resolves (`${NAME}`, `${env:NAME}`, `${input:id}`), after an optional
|
|
447
|
+
* `Bearer`, `Basic` or `Token` scheme: no credential. A reference with a default does not match.
|
|
448
|
+
*/
|
|
449
|
+
const REFERENCE_ONLY = /^(?:(?:bearer|basic|token)[ \t]+)?(?:\$\{(?:input:[\w.-]+|(?:env:)?[A-Za-z_]\w*)\})+$/iu;
|
|
450
|
+
/** Names `isCredentialKey` leaves out that configurations use for a credential field or option. */
|
|
451
|
+
const CREDENTIAL_NAMES = new Set(["auth", "pat", "bearer", "env"]);
|
|
452
|
+
/** `isCredentialKey`, `CREDENTIAL_NAMES`, or a name ending in `key`, `keys`, `header` or `headers`. */
|
|
453
|
+
function isCredentialName(name) {
|
|
454
|
+
const normalized = name.toLowerCase().replace(/[-_]/g, "");
|
|
455
|
+
return (isCredentialKey(name) ||
|
|
456
|
+
CREDENTIAL_NAMES.has(normalized) ||
|
|
457
|
+
/(?:keys?|headers?)$/u.test(normalized));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* An `args` option whose next item is its value and names a credential: `--api-key`, `--header`,
|
|
461
|
+
* or `-H`, the header option of curl and `mcp-remote`.
|
|
462
|
+
*/
|
|
463
|
+
function isCredentialOption(item) {
|
|
464
|
+
if (item === "-H")
|
|
465
|
+
return true;
|
|
466
|
+
const name = typeof item === "string" ? /^--?([A-Za-z][\w-]*)$/u.exec(item)?.[1] : undefined;
|
|
467
|
+
return name !== undefined && isCredentialName(name);
|
|
468
|
+
}
|
|
469
|
+
/** `--name=value`, or `NAME=value` as `env` takes it, as an `args` item. */
|
|
470
|
+
const OPTION_VALUE = /^(-{0,2}([A-Za-z][\w-]*)=)(.*)$/su;
|
|
471
|
+
/**
|
|
472
|
+
* The item after Docker's `-e` or `--env`, as an environment value is shown: a variable name alone
|
|
473
|
+
* (passed through from the environment) as is, `NAME=VALUE` with its value redacted. Anything else,
|
|
474
|
+
* such as the script after `node -e`, can hold a literal credential in any shape and is redacted.
|
|
475
|
+
*/
|
|
476
|
+
function redactEnvironmentItem(item) {
|
|
477
|
+
if (/^[A-Za-z_]\w*$/u.test(item))
|
|
478
|
+
return item;
|
|
479
|
+
const assignment = /^([A-Za-z_]\w*)=(.*)$/su.exec(item);
|
|
480
|
+
if (!assignment)
|
|
481
|
+
return REDACTED;
|
|
482
|
+
return REFERENCE_ONLY.test(assignment[2]) ? item : `${assignment[1]}=${REDACTED}`;
|
|
483
|
+
}
|
|
484
|
+
/** A URL path segment long and mixed enough to be a token, as some servers put their key there. */
|
|
485
|
+
const TOKEN_SEGMENT = /^(?=[^/]*[A-Za-z])(?=[^/]*\d)[^/]{16,}$/u;
|
|
486
|
+
/** Whether a URL path segment is token-like, or a credential `scrubCredentialText` knows. */
|
|
487
|
+
function isTokenSegment(segment) {
|
|
488
|
+
return TOKEN_SEGMENT.test(segment) || scrubCredentialText(segment) !== segment;
|
|
489
|
+
}
|
|
490
|
+
/** A URL with each token-like path segment replaced; any other string is returned as is. */
|
|
491
|
+
function redactUrlPath(value) {
|
|
492
|
+
if (!/^(?:https?|wss?):\/\//iu.test(value))
|
|
493
|
+
return value;
|
|
494
|
+
let parsed;
|
|
495
|
+
try {
|
|
496
|
+
parsed = new URL(value);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
const segments = parsed.pathname.split("/");
|
|
502
|
+
if (!segments.some(isTokenSegment))
|
|
503
|
+
return value;
|
|
504
|
+
parsed.pathname = segments
|
|
505
|
+
.map((segment) => (isTokenSegment(segment) ? REDACTED : segment))
|
|
506
|
+
.join("/");
|
|
507
|
+
return parsed.href;
|
|
508
|
+
}
|
|
509
|
+
/** A query parameter name shown before its redacted value; any other becomes `[redacted]` whole. */
|
|
510
|
+
const PARAMETER_NAME = /^[A-Za-z_][\w.-]{0,31}$/u;
|
|
511
|
+
/**
|
|
512
|
+
* The URL this command writes, as its output shows it: the selections it validated (`toolsets`,
|
|
513
|
+
* `project`, and `read_only` when true, false, 1 or 0) are kept; any other query value, and a
|
|
514
|
+
* token-like path segment, becomes `[redacted]`. `parseMcpUrl` has already refused userinfo and a
|
|
515
|
+
* fragment. Commands printed for a person to run keep the URL as given.
|
|
516
|
+
*/
|
|
517
|
+
function displayMcpUrl(url) {
|
|
518
|
+
const parsed = new URL(url);
|
|
519
|
+
const query = parsed.search
|
|
520
|
+
.slice(1)
|
|
521
|
+
.split("&")
|
|
522
|
+
.filter(Boolean)
|
|
523
|
+
.map((pair) => {
|
|
524
|
+
const [name = "", value = ""] = pair.split("=", 2);
|
|
525
|
+
const selection = name === "toolsets" ||
|
|
526
|
+
name === "project" ||
|
|
527
|
+
(name === "read_only" && /^(?:true|false|1|0)$/u.test(value));
|
|
528
|
+
if (selection)
|
|
529
|
+
return pair;
|
|
530
|
+
return PARAMETER_NAME.test(name) &&
|
|
531
|
+
!TOKEN_SEGMENT.test(name) &&
|
|
532
|
+
scrubCredentialText(name) === name
|
|
533
|
+
? `${name}=${REDACTED}`
|
|
534
|
+
: REDACTED;
|
|
535
|
+
});
|
|
536
|
+
return redactUrlPath(`${parsed.origin}${parsed.pathname}${query.length ? `?${query.join("&")}` : ""}`);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* A merged configuration as `--dry-run` prints it. Header and `env` values, strings and numbers
|
|
540
|
+
* under a key naming a credential, the value of a credential option in `args` (`--api-key VALUE`,
|
|
541
|
+
* `--key=VALUE`) and token-like URL path segments become `[redacted]` unless they only reference a
|
|
542
|
+
* variable or input; other strings lose what `scrubCredentialText` finds (a known token prefix, a
|
|
543
|
+
* `token=` pair, a URL's userinfo and query values). The server names under `serversKey` are not
|
|
544
|
+
* read as field names. `url`, the address this command writes, is shown as `shownUrl`.
|
|
545
|
+
*/
|
|
546
|
+
function redactConfig(root, serversKey, url, shownUrl) {
|
|
547
|
+
const redact = (value, secret, depth, names) => {
|
|
548
|
+
if (depth > 256)
|
|
549
|
+
return REDACTED;
|
|
550
|
+
if (typeof value === "number")
|
|
551
|
+
return secret ? REDACTED : value;
|
|
552
|
+
if (typeof value === "string") {
|
|
553
|
+
if (value === url)
|
|
554
|
+
return shownUrl;
|
|
555
|
+
if (REFERENCE_ONLY.test(value))
|
|
556
|
+
return value;
|
|
557
|
+
if (secret)
|
|
558
|
+
return REDACTED;
|
|
559
|
+
const option = OPTION_VALUE.exec(value);
|
|
560
|
+
if (!option)
|
|
561
|
+
return scrubCredentialText(redactUrlPath(value));
|
|
562
|
+
if (REFERENCE_ONLY.test(option[3]))
|
|
563
|
+
return value;
|
|
564
|
+
const shown = isCredentialName(option[2])
|
|
565
|
+
? REDACTED
|
|
566
|
+
: scrubCredentialText(redactUrlPath(option[3]));
|
|
567
|
+
return `${option[1]}${shown}`;
|
|
568
|
+
}
|
|
569
|
+
if (Array.isArray(value))
|
|
570
|
+
return value.map((item, index) => {
|
|
571
|
+
const previous = value[index - 1];
|
|
572
|
+
if (!secret && typeof item === "string" && (previous === "-e" || previous === "--env"))
|
|
573
|
+
return redactEnvironmentItem(item);
|
|
574
|
+
return redact(item, secret || isCredentialOption(previous), depth + 1, false);
|
|
575
|
+
});
|
|
576
|
+
if (!isRecord(value))
|
|
577
|
+
return value;
|
|
578
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
579
|
+
key,
|
|
580
|
+
redact(item, secret || (!(names && isRecord(item)) && isCredentialName(key)), depth + 1, depth === 0 && key === serversKey),
|
|
581
|
+
]));
|
|
582
|
+
};
|
|
583
|
+
return redact(root, false, 0, false);
|
|
584
|
+
}
|
|
585
|
+
/** Whether a person is likely watching: login.ts opens a browser on the same test. */
|
|
586
|
+
const isTerminal = (stream) => stream.isTTY === true;
|
|
369
587
|
async function findExecutable(name, env) {
|
|
370
588
|
const searchPath = env.PATH ?? env.Path ?? "";
|
|
371
589
|
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
@@ -411,6 +629,102 @@ function runClientCli(executable, args, options) {
|
|
|
411
629
|
}
|
|
412
630
|
});
|
|
413
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* A client CLI's exit code and output within ten seconds, none of it shown; null when it could not
|
|
634
|
+
* start or ran out of time.
|
|
635
|
+
*/
|
|
636
|
+
function readClientCli(executable, args, options) {
|
|
637
|
+
return new Promise((resolveRun) => {
|
|
638
|
+
try {
|
|
639
|
+
const child = spawn(executable, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
640
|
+
const collect = () => {
|
|
641
|
+
const chunks = [];
|
|
642
|
+
let size = 0;
|
|
643
|
+
return {
|
|
644
|
+
add: (chunk) => {
|
|
645
|
+
size += chunk.length;
|
|
646
|
+
if (size <= MAX_CONFIG_BYTES)
|
|
647
|
+
chunks.push(chunk);
|
|
648
|
+
},
|
|
649
|
+
text: () => (size <= MAX_CONFIG_BYTES ? Buffer.concat(chunks).toString() : null),
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
const out = collect();
|
|
653
|
+
const err = collect();
|
|
654
|
+
let timedOut = false;
|
|
655
|
+
const timer = setTimeout(() => {
|
|
656
|
+
timedOut = true;
|
|
657
|
+
child.kill();
|
|
658
|
+
}, 10_000);
|
|
659
|
+
child.stdout.on("data", out.add);
|
|
660
|
+
child.stderr.on("data", err.add);
|
|
661
|
+
child.once("error", () => {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
resolveRun(null);
|
|
664
|
+
});
|
|
665
|
+
child.once("close", (code) => {
|
|
666
|
+
clearTimeout(timer);
|
|
667
|
+
const stdout = out.text();
|
|
668
|
+
const stderr = err.text();
|
|
669
|
+
resolveRun(timedOut || code === null || stdout === null || stderr === null
|
|
670
|
+
? null
|
|
671
|
+
: { code, stdout, stderr });
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
resolveRun(null);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const hasAuthorization = (headers) => isRecord(headers) && Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
|
|
680
|
+
/** The server's HTTP entry in `./.mcp.json`; an unreadable file is reported by the write. */
|
|
681
|
+
async function readClaudeProjectEntry(cwd, serverName) {
|
|
682
|
+
let config;
|
|
683
|
+
try {
|
|
684
|
+
({ config } = await readJsonConfig(resolve(cwd, ".mcp.json"), ".mcp.json"));
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
const servers = isRecord(config) ? config.mcpServers : undefined;
|
|
690
|
+
const entry = isRecord(servers) ? servers[serverName] : undefined;
|
|
691
|
+
if (!isRecord(entry) || typeof entry.url !== "string" || !parseMcpUrl(entry.url))
|
|
692
|
+
return undefined;
|
|
693
|
+
const auth = hasAuthorization(entry.headers) ? "key" : "oauth";
|
|
694
|
+
return { url: entry.url, auth, source: ".mcp.json" };
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* The server's entry in Codex's configuration, as `codex mcp get --json` reports it: `found` with
|
|
698
|
+
* its exact output, `none` when Codex says it has no such server (or holds one that is not an HTTP
|
|
699
|
+
* Hue entry), or `unknown` when Codex could not be asked or answered otherwise, which is never
|
|
700
|
+
* taken for `none`.
|
|
701
|
+
*/
|
|
702
|
+
async function readCodexEntry(executable, serverName, options) {
|
|
703
|
+
const result = await readClientCli(executable, ["mcp", "get", serverName, "--json"], options);
|
|
704
|
+
if (!result)
|
|
705
|
+
return { state: "unknown" };
|
|
706
|
+
if (result.code !== 0)
|
|
707
|
+
return result.stderr.includes(`No MCP server named '${serverName}' found`)
|
|
708
|
+
? { state: "none" }
|
|
709
|
+
: { state: "unknown" };
|
|
710
|
+
let transport;
|
|
711
|
+
try {
|
|
712
|
+
transport = JSON.parse(result.stdout).transport;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return { state: "unknown" };
|
|
716
|
+
}
|
|
717
|
+
if (!isRecord(transport) || typeof transport.url !== "string" || !parseMcpUrl(transport.url))
|
|
718
|
+
return { state: "none" };
|
|
719
|
+
const key = Boolean(transport.bearer_token_env_var) ||
|
|
720
|
+
hasAuthorization(transport.http_headers) ||
|
|
721
|
+
hasAuthorization(transport.env_http_headers);
|
|
722
|
+
return {
|
|
723
|
+
state: "found",
|
|
724
|
+
entry: { url: transport.url, auth: key ? "key" : "oauth", source: "Codex's configuration" },
|
|
725
|
+
raw: result.stdout,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
414
728
|
/** Validates the MCP endpoint: HTTPS, or HTTP for loopback test servers; no credentials or hash. */
|
|
415
729
|
export function parseMcpUrl(value) {
|
|
416
730
|
let url;
|
|
@@ -525,20 +839,69 @@ function planFor(client, auth, scope, url, serverName) {
|
|
|
525
839
|
}
|
|
526
840
|
}
|
|
527
841
|
}
|
|
528
|
-
|
|
842
|
+
/**
|
|
843
|
+
* The authentication used without `--auth`, and the line saying why. A client that can sign in
|
|
844
|
+
* does, unless a key is asked for: read-only access (`--read-only`, or `read_only=true` in
|
|
845
|
+
* `--url`), which a sign-in connection does not have. For Claude Code and Codex, an existing entry
|
|
846
|
+
* keeps its choice, so rerunning the command, to change `--toolsets` for instance, never swaps a
|
|
847
|
+
* working setup; a key keeps its `read_only`. A new entry uses a key when `HUE_MCP_KEY` is set in
|
|
848
|
+
* `env`; only the variable's presence is checked. Conductor signed in by default before and keeps
|
|
849
|
+
* doing so: its agents read the login-shell environment Conductor captures, not this shell's. The
|
|
850
|
+
* other clients only take a key, so they get no line.
|
|
851
|
+
*/
|
|
852
|
+
function defaultAuth(client, readOnly, existing, env) {
|
|
853
|
+
if (!OAUTH_CLIENTS.includes(client))
|
|
854
|
+
return { auth: "key" };
|
|
855
|
+
if (readOnly)
|
|
856
|
+
return {
|
|
857
|
+
auth: "key",
|
|
858
|
+
note: `Using a key: ${readOnly} needs one, since a sign-in connection has Read and write access.`,
|
|
859
|
+
};
|
|
860
|
+
if (existing?.auth === "key") {
|
|
861
|
+
const keepReadOnly = isReadOnlyUrl(existing.url);
|
|
862
|
+
return {
|
|
863
|
+
auth: "key",
|
|
864
|
+
keepReadOnly,
|
|
865
|
+
note: keepReadOnly
|
|
866
|
+
? `Keeping a read-only key, as the existing entry in ${existing.source} has; pass --auth key to drop read_only, or --auth oauth to sign in with Hue instead.`
|
|
867
|
+
: `Keeping a key, as the existing entry in ${existing.source} has; pass --auth oauth to sign in with Hue instead.`,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
if (existing)
|
|
871
|
+
return {
|
|
872
|
+
auth: "oauth",
|
|
873
|
+
note: `Keeping sign-in with Hue, as the existing entry in ${existing.source} has; pass --auth key to use a key instead.`,
|
|
874
|
+
};
|
|
875
|
+
if (client !== "conductor" && env[ENV_VAR])
|
|
876
|
+
return {
|
|
877
|
+
auth: "key",
|
|
878
|
+
note: `Using a key (${ENV_VAR} is set); pass --auth oauth to sign in with Hue instead.`,
|
|
879
|
+
};
|
|
880
|
+
return {
|
|
881
|
+
auth: "oauth",
|
|
882
|
+
note: `Using sign-in with Hue (${client === "conductor" ? "Conductor's default" : `${ENV_VAR} is not set`}); pass --auth key to use a key instead.`,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function nextSteps(client, auth, serverName, project, scope, readOnly) {
|
|
529
886
|
const label = CLIENT_LABELS[client];
|
|
530
887
|
const approve = project
|
|
531
888
|
? `Sign in to Hue and approve the connection. It acts only on the pinned project ${project}, within your role; for read-only access, use a Read project key with --auth key.`
|
|
532
|
-
: "Sign in to Hue and approve the connection. It can read and write every active project in the organization
|
|
889
|
+
: "Sign in to Hue and approve the connection. It can read and write every active project in the organization the consent page names, within your role; for read-only access, use a Read project key with --auth key.";
|
|
890
|
+
// A read-only configuration needs a key: plain hue login refuses a Read key.
|
|
891
|
+
const stored = readOnly
|
|
892
|
+
? "hue login --keys coding-agent stores it in .env.hue and accepts a Read key"
|
|
893
|
+
: "hue login stores it in .env.hue";
|
|
533
894
|
let lines;
|
|
534
895
|
if (auth === "oauth")
|
|
535
896
|
lines =
|
|
536
897
|
client === "claude-code"
|
|
537
|
-
? [
|
|
898
|
+
? [
|
|
899
|
+
`Restart Claude Code (for example claude --continue)${scope === "project" ? ` and approve ${serverName} from .mcp.json` : ""}, then run /mcp, select ${serverName} and choose Authenticate. ${approve}`,
|
|
900
|
+
]
|
|
538
901
|
: client === "codex"
|
|
539
902
|
? [
|
|
540
903
|
`If Codex did not open Hue in your browser, run: codex mcp login ${serverName}`,
|
|
541
|
-
approve,
|
|
904
|
+
`${approve} Then start a new Codex session, which loads ${serverName}.`,
|
|
542
905
|
]
|
|
543
906
|
: [
|
|
544
907
|
`In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${serverName}'s authentication action. ${approve}`,
|
|
@@ -550,13 +913,13 @@ function nextSteps(client, auth, serverName, project) {
|
|
|
550
913
|
];
|
|
551
914
|
else if (client === "conductor")
|
|
552
915
|
lines = [
|
|
553
|
-
`Conductor agents read ${ENV_VAR} from the login-shell environment Conductor captures, so export it there, not only in a terminal;
|
|
916
|
+
`Conductor agents read ${ENV_VAR} from the login-shell environment Conductor captures, so export it there, not only in a terminal; ${stored}.${readOnly ? "" : " Sign-in (--auth oauth) needs no variable."}`,
|
|
554
917
|
];
|
|
555
918
|
else
|
|
556
919
|
lines = [
|
|
557
|
-
`Export ${ENV_VAR} in the shell that starts ${label};
|
|
920
|
+
`Export ${ENV_VAR} in the shell that starts ${label}; ${stored}:`,
|
|
558
921
|
" set -a; . ./.env.hue; set +a",
|
|
559
|
-
`An app started from the Dock or a launcher does not see that shell's variables; start ${label} from the shell${OAUTH_CLIENTS.includes(client) ? ", or sign in instead with --auth oauth" : ""}.`,
|
|
922
|
+
`An app started from the Dock or a launcher does not see that shell's variables; start ${label} from the shell${OAUTH_CLIENTS.includes(client) && !readOnly ? ", or sign in instead with --auth oauth" : ""}.`,
|
|
560
923
|
];
|
|
561
924
|
// `codex mcp add` has no option for it, so a registered server gets the line by hand.
|
|
562
925
|
if (client === "codex" || client === "conductor")
|
|
@@ -602,10 +965,10 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
602
965
|
if (!client || !CLIENT_IDS.includes(client))
|
|
603
966
|
return fail(`${client ? `Unknown client: ${client}.` : "--client is required."} Choose one of ${CLIENT_IDS.join(", ")}.\n\n${MCP_USAGE}`, 2);
|
|
604
967
|
const clientId = client;
|
|
605
|
-
const
|
|
606
|
-
if (
|
|
968
|
+
const explicitAuth = parsed.values.auth;
|
|
969
|
+
if (explicitAuth !== undefined && explicitAuth !== "key" && explicitAuth !== "oauth")
|
|
607
970
|
return fail(`--auth must be key or oauth.\n\n${MCP_USAGE}`, 2);
|
|
608
|
-
if (
|
|
971
|
+
if (explicitAuth === "oauth" && !OAUTH_CLIENTS.includes(clientId))
|
|
609
972
|
return fail(clientId === "cursor"
|
|
610
973
|
? "--auth oauth is not available for cursor: Hue does not yet accept Cursor's sign-in callback. Use a key (the default), or sign in from claude-code or codex."
|
|
611
974
|
: `--auth oauth is available for ${OAUTH_CLIENTS.join(", ")}. Use a key (the default) for ${clientId}.`, 2);
|
|
@@ -617,12 +980,6 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
617
980
|
let url = parseMcpUrl(parsed.values.url ?? DEFAULT_MCP_URL);
|
|
618
981
|
if (!url)
|
|
619
982
|
return fail("--url must be an HTTPS URL such as https://mcp.hue.run/mcp (plain HTTP is accepted for loopback test servers only).", 2);
|
|
620
|
-
// A sign-in connection's access is chosen when it is approved; read_only is not part of it.
|
|
621
|
-
if (auth === "oauth" &&
|
|
622
|
-
(parsed.values["read-only"] || new URL(url).searchParams.has("read_only")))
|
|
623
|
-
return fail("A sign-in connection has Read and write access and cannot be made read-only by its URL. For read-only access, use a Read project key (--auth key), or add --read-only to a key configuration.", 2);
|
|
624
|
-
if (parsed.values["read-only"])
|
|
625
|
-
url = readOnlyMcpUrl(url);
|
|
626
983
|
// --project replaces a pin already in --url. Without it, validate and keep the URL's pin.
|
|
627
984
|
const urlProjects = new URL(url).searchParams.getAll("project");
|
|
628
985
|
let projectValue = parsed.values.project;
|
|
@@ -641,6 +998,40 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
641
998
|
project = selected.project;
|
|
642
999
|
serverName = selected.serverName;
|
|
643
1000
|
}
|
|
1001
|
+
const urlReadOnly = new URL(url).searchParams.has("read_only");
|
|
1002
|
+
const readOnly = parsed.values["read-only"]
|
|
1003
|
+
? "--read-only"
|
|
1004
|
+
: isReadOnlyUrl(url)
|
|
1005
|
+
? "read_only in --url"
|
|
1006
|
+
: undefined;
|
|
1007
|
+
// Without --auth or read_only, an existing entry keeps its choice. A user-scope `claude mcp add`
|
|
1008
|
+
// refuses a name it already has, and Conductor keeps its default, so only these two are read.
|
|
1009
|
+
let existing;
|
|
1010
|
+
if (explicitAuth === undefined && !parsed.values["read-only"] && !urlReadOnly) {
|
|
1011
|
+
if (clientId === "claude-code" && scope === "project")
|
|
1012
|
+
existing = await readClaudeProjectEntry(cwd, serverName);
|
|
1013
|
+
else if (clientId === "codex") {
|
|
1014
|
+
// Without codex on PATH nothing is run, so nothing Codex holds is replaced. With it, an
|
|
1015
|
+
// entry that cannot be read may hold either a key or a sign-in, so the command does not
|
|
1016
|
+
// guess which to keep.
|
|
1017
|
+
const codex = await findExecutable("codex", env);
|
|
1018
|
+
const lookup = codex ? await readCodexEntry(codex, serverName, { cwd, env }) : undefined;
|
|
1019
|
+
if (lookup?.state === "unknown")
|
|
1020
|
+
return fail(`Could not read Codex's "${serverName}" entry (codex mcp get failed), so the command will not guess whether to keep a key or sign-in. Pass --auth oauth or --auth key.`, 2);
|
|
1021
|
+
if (lookup?.state === "found")
|
|
1022
|
+
existing = lookup.entry;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const { auth, keepReadOnly, note } = explicitAuth === undefined
|
|
1026
|
+
? defaultAuth(clientId, readOnly, existing, env)
|
|
1027
|
+
: { auth: explicitAuth };
|
|
1028
|
+
// A sign-in connection's access is chosen when it is approved; read_only is not part of it.
|
|
1029
|
+
if (auth === "oauth" && readOnly)
|
|
1030
|
+
return fail("A sign-in connection has Read and write access and cannot be made read-only by its URL. For read-only access, use a Read project key (--auth key), or add --read-only to a key configuration.", 2);
|
|
1031
|
+
if (auth === "oauth" && urlReadOnly)
|
|
1032
|
+
return fail("Remove read_only from --url: a sign-in URL does not carry it, since a sign-in connection has Read and write access, and Hue reads only read_only=true or 1. For read-only access, use a Read project key (--auth key).", 2);
|
|
1033
|
+
if (parsed.values["read-only"] || keepReadOnly)
|
|
1034
|
+
url = readOnlyMcpUrl(url);
|
|
644
1035
|
url = projectMcpUrl(url, project);
|
|
645
1036
|
// --toolsets wins over a selection already in --url, which wins over the client's default.
|
|
646
1037
|
// Both sources are validated: Hue ignores an unknown name and would list its default.
|
|
@@ -653,12 +1044,18 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
653
1044
|
toolsets = selected.toolsets;
|
|
654
1045
|
}
|
|
655
1046
|
url = toolsetsMcpUrl(url, toolsets);
|
|
1047
|
+
// On stderr, so --print and --dry-run output stays as it was.
|
|
1048
|
+
if (note)
|
|
1049
|
+
stderr.write(`${note}\n`);
|
|
1050
|
+
const shownUrl = displayMcpUrl(url);
|
|
1051
|
+
// What the command reports doing; a command a person is to run is printed as given.
|
|
1052
|
+
const shownCommand = (command) => command.display.replaceAll(shellWord(url), () => shellWord(shownUrl));
|
|
656
1053
|
const plan = planFor(clientId, auth, scope, url, serverName);
|
|
657
1054
|
if (parsed.values.print) {
|
|
658
1055
|
stdout.write(plan.snippet);
|
|
659
1056
|
return 0;
|
|
660
1057
|
}
|
|
661
|
-
const steps = nextSteps(clientId, auth, serverName, project);
|
|
1058
|
+
const steps = nextSteps(clientId, auth, serverName, project, scope, isReadOnlyUrl(url));
|
|
662
1059
|
if (plan.kind === "manual") {
|
|
663
1060
|
out(plan.hint);
|
|
664
1061
|
stdout.write(plan.snippet);
|
|
@@ -669,10 +1066,13 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
669
1066
|
if (plan.kind === "cli") {
|
|
670
1067
|
if (parsed.values["dry-run"]) {
|
|
671
1068
|
for (const command of plan.commands)
|
|
672
|
-
out(`Would run: ${command
|
|
1069
|
+
out(`Would run: ${shownCommand(command)}`);
|
|
673
1070
|
return 0;
|
|
674
1071
|
}
|
|
675
1072
|
let failed = false;
|
|
1073
|
+
// `codex mcp add` signs in at once and waits up to five minutes for the browser. Outside a
|
|
1074
|
+
// terminal (CI, an agent's shell tool) nobody may finish that, so a default sign-in prints it.
|
|
1075
|
+
const deferCodex = auth === "oauth" && explicitAuth === undefined && !isTerminal(stdout);
|
|
676
1076
|
for (const command of plan.commands) {
|
|
677
1077
|
const executable = await findExecutable(command.executable, env);
|
|
678
1078
|
if (!executable) {
|
|
@@ -681,12 +1081,36 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
681
1081
|
out(command.display);
|
|
682
1082
|
continue;
|
|
683
1083
|
}
|
|
684
|
-
|
|
1084
|
+
if (deferCodex && command.executable === "codex") {
|
|
1085
|
+
out("Not running codex mcp add: it signs in at once and waits up to 5 minutes for the browser, and no terminal is attached. Run it where you can sign in, or pass --auth oauth to run it here and wait:");
|
|
1086
|
+
out(command.display);
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
// What Codex held before, so a failed add is not mistaken for this run's registration.
|
|
1090
|
+
const codexSignIn = auth === "oauth" && command.executable === "codex";
|
|
1091
|
+
const before = codexSignIn
|
|
1092
|
+
? await readCodexEntry(executable, serverName, { cwd, env })
|
|
1093
|
+
: undefined;
|
|
1094
|
+
out(`Running: ${shownCommand(command)}`);
|
|
685
1095
|
const code = await runClientCli(executable, command.args, { cwd, env, stdout, stderr });
|
|
686
1096
|
if (code === 0) {
|
|
687
|
-
out(`Registered the "${serverName}" MCP server (${
|
|
1097
|
+
out(`Registered the "${serverName}" MCP server (${shownUrl}) with ${command.label}.`);
|
|
688
1098
|
continue;
|
|
689
1099
|
}
|
|
1100
|
+
// Codex writes its entry before signing in, so a sign-in that timed out or was denied
|
|
1101
|
+
// leaves the server registered; `codex mcp login` signs in again. Only an entry this run
|
|
1102
|
+
// wrote counts: one Codex already held unchanged proves nothing about this add.
|
|
1103
|
+
if (code !== null && codexSignIn) {
|
|
1104
|
+
const after = await readCodexEntry(executable, serverName, { cwd, env });
|
|
1105
|
+
const wrote = after.state === "found" &&
|
|
1106
|
+
after.entry.auth === "oauth" &&
|
|
1107
|
+
after.entry.url === url &&
|
|
1108
|
+
(before?.state === "none" || (before?.state === "found" && before.raw !== after.raw));
|
|
1109
|
+
if (wrote) {
|
|
1110
|
+
out(`Registered the "${serverName}" MCP server (${shownUrl}) with Codex, but its sign-in did not finish. Run codex mcp login ${serverName} to sign in.`);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
690
1114
|
failed = true;
|
|
691
1115
|
stderr.write(`${command.executable} ${code === null ? "could not be started" : `exited with code ${code}`}. Run this command yourself:\n${command.display}\n`);
|
|
692
1116
|
}
|
|
@@ -698,10 +1122,14 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
698
1122
|
}
|
|
699
1123
|
const path = resolve(cwd, plan.file);
|
|
700
1124
|
const display = displayPath(cwd, path);
|
|
1125
|
+
let merged;
|
|
701
1126
|
let content;
|
|
1127
|
+
let read;
|
|
702
1128
|
try {
|
|
1129
|
+
await rejectLinkedParents(cwd, path, display);
|
|
703
1130
|
const existing = await readJsonConfig(path, display);
|
|
704
|
-
|
|
1131
|
+
read = existing.file;
|
|
1132
|
+
merged = mergeServerEntry(existing.config, plan.key, plan.entry, display, serverName);
|
|
705
1133
|
if (plan.input)
|
|
706
1134
|
merged = mergeVscodeInput(merged, plan.input, display);
|
|
707
1135
|
content = json(merged);
|
|
@@ -712,17 +1140,21 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
712
1140
|
return fail(`Could not read ${display}: ${error.message}`);
|
|
713
1141
|
}
|
|
714
1142
|
if (parsed.values["dry-run"]) {
|
|
715
|
-
|
|
716
|
-
|
|
1143
|
+
// Other servers' entries can hold literal tokens; the file keeps them, the output does not.
|
|
1144
|
+
const shown = json(redactConfig(merged, plan.key, url, shownUrl));
|
|
1145
|
+
out(shown === content
|
|
1146
|
+
? `Would write ${display}:`
|
|
1147
|
+
: `Would write ${display} (credential values shown as ${REDACTED}):`);
|
|
1148
|
+
stdout.write(shown);
|
|
717
1149
|
return 0;
|
|
718
1150
|
}
|
|
719
1151
|
try {
|
|
720
|
-
await writeConfigFile(path, content, display);
|
|
1152
|
+
await writeConfigFile(cwd, path, content, display, read);
|
|
721
1153
|
}
|
|
722
1154
|
catch (error) {
|
|
723
1155
|
return fail(`Could not write ${display}: ${error.message}`);
|
|
724
1156
|
}
|
|
725
|
-
out(`Wrote ${display} with the "${serverName}" MCP server (${
|
|
1157
|
+
out(`Wrote ${display} with the "${serverName}" MCP server (${shownUrl}).`);
|
|
726
1158
|
for (const line of steps)
|
|
727
1159
|
out(line);
|
|
728
1160
|
return 0;
|