@hue-run/sdk 0.11.1 → 0.11.3

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/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, chmod, lstat, mkdir, open, readFile, rename, stat, unlink, } from "node:fs/promises";
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";
@@ -16,7 +17,7 @@ const MAX_CONFIG_BYTES = 1024 * 1024;
16
17
  * lists them, after which each call takes `project_id`. It reads what needs attention and falls
17
18
  * back to recent traces, so a project without errors still proves the read path.
18
19
  */
19
- export const MCP_VERIFY_PROMPT = "Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id as project_id on each call after list_projects.";
20
+ export const MCP_VERIFY_PROMPT = "Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id or slug as project_id on each call after list_projects.";
20
21
  const CLIENT_IDS = [
21
22
  "claude-code",
22
23
  "codex",
@@ -37,13 +38,19 @@ const CLIENT_LABELS = {
37
38
  };
38
39
  /** Clients whose browser sign-in with Hue works against the deployed server. */
39
40
  const OAUTH_CLIENTS = ["claude-code", "codex", "conductor"];
40
- /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and the `observe` profile. */
41
+ /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and its curated profiles. */
41
42
  export const MCP_TOOLSETS = [
42
43
  "all",
43
44
  "observe",
45
+ "author",
46
+ "evaluate",
44
47
  "project",
45
48
  "traces",
46
- "evals",
49
+ "eval_sets",
50
+ "runs",
51
+ "judges",
52
+ "cases",
53
+ "runners",
47
54
  "environments",
48
55
  "intents",
49
56
  "docs",
@@ -53,6 +60,8 @@ export const MCP_TOOLSETS = [
53
60
  * keep that URL bare; every other configuration carries the selection in its URL.
54
61
  */
55
62
  export const TOOLSETS_HEADER = "X-Hue-MCP-Toolsets";
63
+ /** The header form of `?project=`, used by the sign-in Codex TOML to keep its URL bare. */
64
+ export const PROJECT_HEADER = "X-Hue-MCP-Project";
56
65
  /**
57
66
  * Toolsets a client's configuration selects unless `--toolsets` says otherwise. Claude Code,
58
67
  * Codex and Conductor's agents defer MCP tools behind their own tool search, so listing every tool
@@ -69,7 +78,8 @@ const DEFAULT_TOOLSETS = {
69
78
  /** Codex runs a server's tool calls one at a time without it; each Hue call is independent. */
70
79
  const CODEX_PARALLEL = "supports_parallel_tool_calls = true";
71
80
  export const MCP_USAGE = `Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>
72
- [--auth key|oauth] [--read-only] [--toolsets NAMES] [--url URL]
81
+ [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]
82
+ [--toolsets NAMES] [--url URL]
73
83
  [--scope project|user] [--dry-run] [--print]
74
84
 
75
85
  Configure a coding agent to use the Hue MCP server. A key configuration references the
@@ -84,10 +94,13 @@ Options:
84
94
  conductor; the default for conductor)
85
95
  --read-only key only: add ?read_only=true to the URL so write tools are hidden. A
86
96
  sign-in connection has Read and write access; use a Read key for read-only
97
+ --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,
98
+ and the server is named hue-<value> so it can coexist with an unpinned hue
87
99
  --toolsets NAMES
88
- Tools to list, comma-separated: all, observe (production reads),
89
- project, traces, evals, environments, intents or docs, added to the
90
- URL as ?toolsets=. Defaults: all for claude-code, codex and conductor,
100
+ Tools to list, comma-separated: all; the observe, author or evaluate
101
+ profiles; or project, traces, eval_sets, runs, judges, cases, runners,
102
+ environments, intents or docs. Added to the URL as ?toolsets=.
103
+ Defaults: all for claude-code, codex and conductor,
91
104
  which search their own tools; observe for cursor. Without a selection
92
105
  Hue lists the production reads and search_hue_tools, which reaches
93
106
  every other tool
@@ -114,7 +127,7 @@ export function shellWord(value) {
114
127
  * Canonical Hue client snippets for a key configuration; the JSON values are also the merge
115
128
  * entries for config files.
116
129
  */
117
- export function renderMcpSnippets(url) {
130
+ export function renderMcpSnippets(url, serverName = SERVER_NAME) {
118
131
  const bearer = (reference) => `Bearer ${reference}`;
119
132
  const claudeCodeServer = {
120
133
  type: "http",
@@ -139,7 +152,7 @@ export function renderMcpSnippets(url) {
139
152
  };
140
153
  return {
141
154
  claudeCodeServer,
142
- claudeCodeProjectJson: json({ mcpServers: { [SERVER_NAME]: claudeCodeServer } }),
155
+ claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
143
156
  claudeCodeCli: {
144
157
  args: [
145
158
  "mcp",
@@ -148,25 +161,25 @@ export function renderMcpSnippets(url) {
148
161
  "http",
149
162
  "--scope",
150
163
  "user",
151
- SERVER_NAME,
164
+ serverName,
152
165
  url,
153
166
  "--header",
154
167
  `Authorization: Bearer \${${ENV_VAR}}`,
155
168
  ],
156
- display: `claude mcp add --transport http --scope user ${SERVER_NAME} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
169
+ display: `claude mcp add --transport http --scope user ${serverName} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
157
170
  },
158
171
  cursorServer,
159
- cursorJson: json({ mcpServers: { [SERVER_NAME]: cursorServer } }),
172
+ cursorJson: json({ mcpServers: { [serverName]: cursorServer } }),
160
173
  codexCli: {
161
- args: ["mcp", "add", SERVER_NAME, "--url", url, "--bearer-token-env-var", ENV_VAR],
162
- display: `codex mcp add ${SERVER_NAME} --url ${shellWord(url)} --bearer-token-env-var ${ENV_VAR}`,
174
+ args: ["mcp", "add", serverName, "--url", url, "--bearer-token-env-var", ENV_VAR],
175
+ display: `codex mcp add ${serverName} --url ${shellWord(url)} --bearer-token-env-var ${ENV_VAR}`,
163
176
  },
164
- codexToml: `[mcp_servers.${SERVER_NAME}]\nurl = "${url}"\nbearer_token_env_var = "${ENV_VAR}"\n${CODEX_PARALLEL}\n`,
177
+ codexToml: `[mcp_servers.${serverName}]\nurl = "${url}"\nbearer_token_env_var = "${ENV_VAR}"\n${CODEX_PARALLEL}\n`,
165
178
  vscodeServer,
166
179
  vscodeInput,
167
- vscodeJson: json({ servers: { [SERVER_NAME]: vscodeServer }, inputs: [vscodeInput] }),
180
+ vscodeJson: json({ servers: { [serverName]: vscodeServer }, inputs: [vscodeInput] }),
168
181
  windsurfServer,
169
- windsurfJson: json({ mcpServers: { [SERVER_NAME]: windsurfServer } }),
182
+ windsurfJson: json({ mcpServers: { [serverName]: windsurfServer } }),
170
183
  geminiCli: {
171
184
  args: [
172
185
  "mcp",
@@ -175,38 +188,44 @@ export function renderMcpSnippets(url) {
175
188
  "user",
176
189
  "--transport",
177
190
  "http",
178
- SERVER_NAME,
191
+ serverName,
179
192
  url,
180
193
  "--header",
181
194
  `Authorization: Bearer \${${ENV_VAR}}`,
182
195
  ],
183
196
  // Single quotes keep the reference literal when a person runs this in a shell where the
184
197
  // key is exported; Gemini CLI expands ${HUE_MCP_KEY} from its settings at connection time.
185
- display: `gemini mcp add --scope user --transport http ${SERVER_NAME} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
198
+ display: `gemini mcp add --scope user --transport http ${serverName} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
186
199
  },
187
200
  };
188
201
  }
189
202
  /**
190
203
  * Sign-in snippets: the server URL and nothing secret. The client discovers Hue's OAuth metadata
191
204
  * from it and sends it, query included, on every request; the token's resource ignores the query,
192
- * so `?toolsets=` selects tools here as it does for a key.
205
+ * so `?toolsets=` and `?project=` select tools and a project here as they do for a key.
193
206
  */
194
- export function renderMcpSignInSnippets(url) {
195
- const toolsets = new URL(url).searchParams.get("toolsets");
207
+ export function renderMcpSignInSnippets(url, serverName = SERVER_NAME) {
208
+ const parsed = new URL(url);
209
+ const toolsets = parsed.searchParams.get("toolsets");
210
+ const project = parsed.searchParams.get("project");
211
+ const headers = [
212
+ toolsets ? `"${TOOLSETS_HEADER}" = "${toolsets}"` : null,
213
+ project ? `"${PROJECT_HEADER}" = "${project}"` : null,
214
+ ].filter((value) => value !== null);
196
215
  const claudeCodeServer = { type: "http", url };
197
216
  return {
198
217
  claudeCodeServer,
199
- claudeCodeProjectJson: json({ mcpServers: { [SERVER_NAME]: claudeCodeServer } }),
218
+ claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
200
219
  claudeCodeCli: {
201
- args: ["mcp", "add", "--transport", "http", "--scope", "user", SERVER_NAME, url],
202
- display: `claude mcp add --transport http --scope user ${SERVER_NAME} ${shellWord(url)}`,
220
+ args: ["mcp", "add", "--transport", "http", "--scope", "user", serverName, url],
221
+ display: `claude mcp add --transport http --scope user ${serverName} ${shellWord(url)}`,
203
222
  },
204
223
  codexCli: {
205
- args: ["mcp", "add", SERVER_NAME, "--url", url],
206
- display: `codex mcp add ${SERVER_NAME} --url ${shellWord(url)}`,
224
+ args: ["mcp", "add", serverName, "--url", url],
225
+ display: `codex mcp add ${serverName} --url ${shellWord(url)}`,
207
226
  },
208
- // The header form of the same selection, for a TOML entry that keeps its URL bare.
209
- codexToml: `[mcp_servers.${SERVER_NAME}]\nurl = "${toolsetsMcpUrl(url, undefined)}"\n${toolsets ? `http_headers = { "${TOOLSETS_HEADER}" = "${toolsets}" }\n` : ""}${CODEX_PARALLEL}\n`,
227
+ // Header forms of the selections, for a TOML entry that keeps its URL bare.
228
+ codexToml: `[mcp_servers.${serverName}]\nurl = "${projectMcpUrl(toolsetsMcpUrl(url, undefined), undefined)}"\n${headers.length ? `http_headers = { ${headers.join(", ")} }\n` : ""}${CODEX_PARALLEL}\n`,
210
229
  };
211
230
  }
212
231
  /**
@@ -237,6 +256,25 @@ export function toolsetsMcpUrl(url, toolsets) {
237
256
  parsed.search = `${query ? `${query}&` : ""}toolsets=${toolsets}`;
238
257
  return parsed.href;
239
258
  }
259
+ /** Parses the id or slug used to pin a connection and derives its coexisting server name. */
260
+ export function parseProject(value) {
261
+ const project = value.trim();
262
+ if (!project)
263
+ return { error: "--project is empty. Name a project id or slug, or remove the option." };
264
+ if (project.length > 256 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/iu.test(project))
265
+ return {
266
+ error: "--project takes a project id or slug, using letters, numbers and hyphens.",
267
+ };
268
+ return { project, serverName: `${SERVER_NAME}-${project.toLowerCase()}` };
269
+ }
270
+ /** Replaces `?project=` while preserving the URL's other selections. */
271
+ export function projectMcpUrl(url, project) {
272
+ const parsed = new URL(url);
273
+ parsed.searchParams.delete("project");
274
+ if (project)
275
+ parsed.searchParams.append("project", project);
276
+ return parsed.href;
277
+ }
240
278
  /** `?read_only=true` hides and rejects Hue's write tools for any key. */
241
279
  export function readOnlyMcpUrl(url) {
242
280
  const parsed = new URL(url);
@@ -252,15 +290,15 @@ class ConfigError extends Error {
252
290
  function isRecord(value) {
253
291
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
292
  }
255
- /** Replaces only the `hue` entry under `key`, keeping every other server and top-level field. */
256
- function mergeServerEntry(existing, key, entry, display) {
293
+ /** Replaces only the selected server entry under `key`, keeping every other server and field. */
294
+ function mergeServerEntry(existing, key, entry, display, serverName = SERVER_NAME) {
257
295
  if (existing !== undefined && !isRecord(existing))
258
296
  throw new ConfigError(`${display} must contain a JSON object.`);
259
297
  const root = existing ?? {};
260
298
  const servers = root[key];
261
299
  if (servers !== undefined && !isRecord(servers))
262
300
  throw new ConfigError(`${display}: "${key}" must be a JSON object.`);
263
- return { ...root, [key]: { ...(servers ?? {}), [SERVER_NAME]: entry } };
301
+ return { ...root, [key]: { ...(servers ?? {}), [serverName]: entry } };
264
302
  }
265
303
  function mergeVscodeInput(root, input, display) {
266
304
  const inputs = root.inputs;
@@ -269,6 +307,7 @@ function mergeVscodeInput(root, input, display) {
269
307
  const others = (inputs ?? []).filter((item) => !(isRecord(item) && item.id === INPUT_ID));
270
308
  return { ...root, inputs: [...others, input] };
271
309
  }
310
+ /** The parsed file, if any, and the file it was read from, so the write can replace that one. */
272
311
  async function readJsonConfig(path, display) {
273
312
  let info;
274
313
  try {
@@ -276,7 +315,7 @@ async function readJsonConfig(path, display) {
276
315
  }
277
316
  catch (error) {
278
317
  if (error.code === "ENOENT")
279
- return undefined;
318
+ return { config: undefined };
280
319
  throw new ConfigError(`Cannot read ${display}: ${error.message}`);
281
320
  }
282
321
  if (info.isSymbolicLink())
@@ -287,40 +326,104 @@ async function readJsonConfig(path, display) {
287
326
  throw new ConfigError(`Refusing to use ${display}: it is larger than 1 MiB.`);
288
327
  const text = await readFile(path, "utf8");
289
328
  if (!text.trim())
290
- return undefined;
329
+ return { config: undefined, file: info };
291
330
  try {
292
- return JSON.parse(text);
331
+ return { config: JSON.parse(text), file: info };
293
332
  }
294
333
  catch {
295
334
  throw new ConfigError(`${display} is not valid JSON (comments are not supported); fix it or add the snippet by hand.`);
296
335
  }
297
336
  }
337
+ /**
338
+ * Refuses a symbolic link, or anything but a directory, between the working directory and the file
339
+ * (`.cursor` or `.vscode`), as the file itself is refused. The working directory and its ancestors
340
+ * are not checked: on macOS `/tmp` and `/var` are links, and a home directory can be one. This runs
341
+ * before the file is read and again before the rename. Node has no `openat`, so it cannot pin the
342
+ * directory against an account that can write the project and swaps a link in after the last check.
343
+ */
344
+ async function rejectLinkedParents(cwd, path, display) {
345
+ let current = cwd;
346
+ for (const part of relative(cwd, dirname(path)).split(sep).filter(Boolean)) {
347
+ current = join(current, part);
348
+ let info;
349
+ try {
350
+ info = await lstat(current);
351
+ }
352
+ catch (error) {
353
+ if (error.code === "ENOENT")
354
+ return;
355
+ throw error;
356
+ }
357
+ const shown = displayPath(cwd, current);
358
+ if (info.isSymbolicLink())
359
+ throw new ConfigError(`Refusing to use ${display}: ${shown} is a symbolic link.`);
360
+ if (!info.isDirectory())
361
+ throw new ConfigError(`Refusing to use ${display}: ${shown} is not a directory.`);
362
+ }
363
+ }
364
+ /** The file's status, or undefined when there is none; a symbolic link is refused. */
298
365
  async function rejectSymlink(path, display) {
366
+ let info;
299
367
  try {
300
- if ((await lstat(path)).isSymbolicLink())
301
- throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
368
+ info = await lstat(path);
302
369
  }
303
370
  catch (error) {
304
371
  if (error.code === "ENOENT")
305
- return;
372
+ return undefined;
306
373
  throw error;
307
374
  }
375
+ if (info.isSymbolicLink())
376
+ throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
377
+ return info;
308
378
  }
309
- /** Atomic write for a secret-free config file: temporary file, fsync, rename; mode 0644. */
310
- async function writeConfigFile(path, text, display) {
379
+ /**
380
+ * Atomic write: temporary file, fsync, rename. A file it replaces keeps its permission bits, so one
381
+ * kept at 0600 because it holds other servers' tokens is never widened, except that other accounts
382
+ * lose write access; it keeps its group (or, when the group cannot be kept, its group and other
383
+ * accounts get only the access both had), and its owner when root runs the command. A new file is
384
+ * created owner-only, 0600 narrowed by the umask: people and clients add literal tokens to these
385
+ * files.
386
+ */
387
+ async function writeConfigFile(cwd, path, text, display, read) {
311
388
  await mkdir(dirname(path), { recursive: true });
312
- await rejectSymlink(path, display);
389
+ const existing = await rejectSymlink(path, display);
390
+ // The file replaced must be the one read: the content merged it, and its mode and group are kept.
391
+ // Another file in its place, such as one in a directory swapped in meanwhile, is refused.
392
+ if (existing?.dev !== read?.dev || existing?.ino !== read?.ino)
393
+ throw new ConfigError(`Refusing to write ${display}: it changed while the command ran.`);
313
394
  const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
314
395
  try {
315
- const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
396
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
316
397
  try {
317
398
  await handle.writeFile(text, "utf8");
399
+ if (existing) {
400
+ // Other accounts never keep write access: they could add a server command to run.
401
+ let mode = existing.mode & 0o775;
402
+ // The new file has the process's group (the directory's on macOS), which may differ. When
403
+ // the group cannot be kept, its members and everyone else get only what both had.
404
+ const created = await handle.stat();
405
+ if (created.gid !== existing.gid) {
406
+ try {
407
+ await handle.chown(-1, existing.gid);
408
+ }
409
+ catch {
410
+ const shared = (mode >> 3) & mode & 0o007;
411
+ mode = (mode & 0o700) | (shared << 3) | shared;
412
+ }
413
+ }
414
+ await handle.chmod(mode);
415
+ // Root also gives the file back to its owner, last: changing the mode of another account's
416
+ // file would need CAP_FOWNER. If it cannot, the write fails and the old file stays in place,
417
+ // rather than leaving one its owner cannot read.
418
+ if (process.geteuid?.() === 0 && created.uid !== existing.uid)
419
+ await handle.chown(existing.uid, -1);
420
+ }
318
421
  await handle.sync();
319
422
  }
320
423
  finally {
321
424
  await handle.close();
322
425
  }
323
- await chmod(temporary, 0o644);
426
+ await rejectLinkedParents(cwd, path, display);
324
427
  await rejectSymlink(path, display);
325
428
  await rename(temporary, path);
326
429
  }
@@ -329,6 +432,147 @@ async function writeConfigFile(path, text, display) {
329
432
  throw error;
330
433
  }
331
434
  }
435
+ const REDACTED = "[redacted]";
436
+ /**
437
+ * Only references a client resolves (`${NAME}`, `${env:NAME}`, `${input:id}`), after an optional
438
+ * `Bearer`, `Basic` or `Token` scheme: no credential. A reference with a default does not match.
439
+ */
440
+ const REFERENCE_ONLY = /^(?:(?:bearer|basic|token)[ \t]+)?(?:\$\{(?:input:[\w.-]+|(?:env:)?[A-Za-z_]\w*)\})+$/iu;
441
+ /** Names `isCredentialKey` leaves out that configurations use for a credential field or option. */
442
+ const CREDENTIAL_NAMES = new Set(["auth", "pat", "bearer", "env"]);
443
+ /** `isCredentialKey`, `CREDENTIAL_NAMES`, or a name ending in `key`, `keys`, `header` or `headers`. */
444
+ function isCredentialName(name) {
445
+ const normalized = name.toLowerCase().replace(/[-_]/g, "");
446
+ return (isCredentialKey(name) ||
447
+ CREDENTIAL_NAMES.has(normalized) ||
448
+ /(?:keys?|headers?)$/u.test(normalized));
449
+ }
450
+ /**
451
+ * An `args` option whose next item is its value and names a credential: `--api-key`, `--header`,
452
+ * or `-H`, the header option of curl and `mcp-remote`.
453
+ */
454
+ function isCredentialOption(item) {
455
+ if (item === "-H")
456
+ return true;
457
+ const name = typeof item === "string" ? /^--?([A-Za-z][\w-]*)$/u.exec(item)?.[1] : undefined;
458
+ return name !== undefined && isCredentialName(name);
459
+ }
460
+ /** `--name=value`, or `NAME=value` as `env` takes it, as an `args` item. */
461
+ const OPTION_VALUE = /^(-{0,2}([A-Za-z][\w-]*)=)(.*)$/su;
462
+ /**
463
+ * The item after Docker's `-e` or `--env`, as an environment value is shown: a variable name alone
464
+ * (passed through from the environment) as is, `NAME=VALUE` with its value redacted. Anything else,
465
+ * such as the script after `node -e`, can hold a literal credential in any shape and is redacted.
466
+ */
467
+ function redactEnvironmentItem(item) {
468
+ if (/^[A-Za-z_]\w*$/u.test(item))
469
+ return item;
470
+ const assignment = /^([A-Za-z_]\w*)=(.*)$/su.exec(item);
471
+ if (!assignment)
472
+ return REDACTED;
473
+ return REFERENCE_ONLY.test(assignment[2]) ? item : `${assignment[1]}=${REDACTED}`;
474
+ }
475
+ /** A URL path segment long and mixed enough to be a token, as some servers put their key there. */
476
+ const TOKEN_SEGMENT = /^(?=[^/]*[A-Za-z])(?=[^/]*\d)[^/]{16,}$/u;
477
+ /** Whether a URL path segment is token-like, or a credential `scrubCredentialText` knows. */
478
+ function isTokenSegment(segment) {
479
+ return TOKEN_SEGMENT.test(segment) || scrubCredentialText(segment) !== segment;
480
+ }
481
+ /** A URL with each token-like path segment replaced; any other string is returned as is. */
482
+ function redactUrlPath(value) {
483
+ if (!/^(?:https?|wss?):\/\//iu.test(value))
484
+ return value;
485
+ let parsed;
486
+ try {
487
+ parsed = new URL(value);
488
+ }
489
+ catch {
490
+ return value;
491
+ }
492
+ const segments = parsed.pathname.split("/");
493
+ if (!segments.some(isTokenSegment))
494
+ return value;
495
+ parsed.pathname = segments
496
+ .map((segment) => (isTokenSegment(segment) ? REDACTED : segment))
497
+ .join("/");
498
+ return parsed.href;
499
+ }
500
+ /** A query parameter name shown before its redacted value; any other becomes `[redacted]` whole. */
501
+ const PARAMETER_NAME = /^[A-Za-z_][\w.-]{0,31}$/u;
502
+ /**
503
+ * The URL this command writes, as its output shows it: the selections it validated (`toolsets`,
504
+ * `project`, and `read_only` when true or false) are kept; any other query value, and a token-like
505
+ * path segment, becomes `[redacted]`. `parseMcpUrl` has already refused userinfo and a fragment.
506
+ * Commands printed for a person to run keep the URL as given.
507
+ */
508
+ function displayMcpUrl(url) {
509
+ const parsed = new URL(url);
510
+ const query = parsed.search
511
+ .slice(1)
512
+ .split("&")
513
+ .filter(Boolean)
514
+ .map((pair) => {
515
+ const [name = "", value = ""] = pair.split("=", 2);
516
+ const selection = name === "toolsets" ||
517
+ name === "project" ||
518
+ (name === "read_only" && /^(?:true|false)$/u.test(value));
519
+ if (selection)
520
+ return pair;
521
+ return PARAMETER_NAME.test(name) &&
522
+ !TOKEN_SEGMENT.test(name) &&
523
+ scrubCredentialText(name) === name
524
+ ? `${name}=${REDACTED}`
525
+ : REDACTED;
526
+ });
527
+ return redactUrlPath(`${parsed.origin}${parsed.pathname}${query.length ? `?${query.join("&")}` : ""}`);
528
+ }
529
+ /**
530
+ * A merged configuration as `--dry-run` prints it. Header and `env` values, strings and numbers
531
+ * under a key naming a credential, the value of a credential option in `args` (`--api-key VALUE`,
532
+ * `--key=VALUE`) and token-like URL path segments become `[redacted]` unless they only reference a
533
+ * variable or input; other strings lose what `scrubCredentialText` finds (a known token prefix, a
534
+ * `token=` pair, a URL's userinfo and query values). The server names under `serversKey` are not
535
+ * read as field names. `url`, the address this command writes, is shown as `shownUrl`.
536
+ */
537
+ function redactConfig(root, serversKey, url, shownUrl) {
538
+ const redact = (value, secret, depth, names) => {
539
+ if (depth > 256)
540
+ return REDACTED;
541
+ if (typeof value === "number")
542
+ return secret ? REDACTED : value;
543
+ if (typeof value === "string") {
544
+ if (value === url)
545
+ return shownUrl;
546
+ if (REFERENCE_ONLY.test(value))
547
+ return value;
548
+ if (secret)
549
+ return REDACTED;
550
+ const option = OPTION_VALUE.exec(value);
551
+ if (!option)
552
+ return scrubCredentialText(redactUrlPath(value));
553
+ if (REFERENCE_ONLY.test(option[3]))
554
+ return value;
555
+ const shown = isCredentialName(option[2])
556
+ ? REDACTED
557
+ : scrubCredentialText(redactUrlPath(option[3]));
558
+ return `${option[1]}${shown}`;
559
+ }
560
+ if (Array.isArray(value))
561
+ return value.map((item, index) => {
562
+ const previous = value[index - 1];
563
+ if (!secret && typeof item === "string" && (previous === "-e" || previous === "--env"))
564
+ return redactEnvironmentItem(item);
565
+ return redact(item, secret || isCredentialOption(previous), depth + 1, false);
566
+ });
567
+ if (!isRecord(value))
568
+ return value;
569
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
570
+ key,
571
+ redact(item, secret || (!(names && isRecord(item)) && isCredentialName(key)), depth + 1, depth === 0 && key === serversKey),
572
+ ]));
573
+ };
574
+ return redact(root, false, 0, false);
575
+ }
332
576
  async function findExecutable(name, env) {
333
577
  const searchPath = env.PATH ?? env.Path ?? "";
334
578
  const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
@@ -398,6 +642,7 @@ function parseMcpArguments(argv) {
398
642
  client: { type: "string" },
399
643
  auth: { type: "string" },
400
644
  "read-only": { type: "boolean", default: false },
645
+ project: { type: "string" },
401
646
  toolsets: { type: "string" },
402
647
  url: { type: "string" },
403
648
  scope: { type: "string" },
@@ -413,10 +658,10 @@ function displayPath(cwd, path) {
413
658
  }
414
659
  const notOnPath = (executable, label) => `${executable} is not on PATH. Run this where ${label} is installed:`;
415
660
  /** `auth: "oauth"` is only planned for {@link OAUTH_CLIENTS}; the caller refuses the others. */
416
- function planFor(client, auth, scope, url) {
417
- const snippets = renderMcpSnippets(url);
661
+ function planFor(client, auth, scope, url, serverName) {
662
+ const snippets = renderMcpSnippets(url, serverName);
418
663
  // Claude Code and Codex take the same plan shape with or without a key.
419
- const shared = auth === "oauth" ? renderMcpSignInSnippets(url) : snippets;
664
+ const shared = auth === "oauth" ? renderMcpSignInSnippets(url, serverName) : snippets;
420
665
  const claudeUser = {
421
666
  executable: "claude",
422
667
  label: "Claude Code",
@@ -487,21 +732,23 @@ function planFor(client, auth, scope, url) {
487
732
  }
488
733
  }
489
734
  }
490
- function nextSteps(client, auth) {
735
+ function nextSteps(client, auth, serverName, project) {
491
736
  const label = CLIENT_LABELS[client];
492
- const approve = "Sign in to Hue and approve the connection. It can read and write every active project in the organization you choose, within your role; for read-only access, use a Read project key with --auth key.";
737
+ const approve = project
738
+ ? `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.`
739
+ : "Sign in to Hue and approve the connection. It can read and write every active project in the organization you choose, within your role; for read-only access, use a Read project key with --auth key.";
493
740
  let lines;
494
741
  if (auth === "oauth")
495
742
  lines =
496
743
  client === "claude-code"
497
- ? [`In Claude Code, run /mcp, select ${SERVER_NAME} and choose Authenticate. ${approve}`]
744
+ ? [`In Claude Code, run /mcp, select ${serverName} and choose Authenticate. ${approve}`]
498
745
  : client === "codex"
499
746
  ? [
500
- `If Codex did not open Hue in your browser, run: codex mcp login ${SERVER_NAME}`,
747
+ `If Codex did not open Hue in your browser, run: codex mcp login ${serverName}`,
501
748
  approve,
502
749
  ]
503
750
  : [
504
- `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${SERVER_NAME}'s authentication action. ${approve}`,
751
+ `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${serverName}'s authentication action. ${approve}`,
505
752
  "Start a new agent session if the Hue tools do not appear. Hue has not yet verified signed-in Conductor end to end; if the tools still do not load, rerun with --auth key.",
506
753
  ];
507
754
  else if (client === "vscode")
@@ -520,8 +767,13 @@ function nextSteps(client, auth) {
520
767
  ];
521
768
  // `codex mcp add` has no option for it, so a registered server gets the line by hand.
522
769
  if (client === "codex" || client === "conductor")
523
- lines.push(`If codex mcp add registered ${SERVER_NAME}, add this line under [mcp_servers.${SERVER_NAME}] in ~/.codex/config.toml so Codex runs Hue's tool calls in parallel:`, ` ${CODEX_PARALLEL}`);
524
- return [...lines, "Then ask your agent:", ` ${MCP_VERIFY_PROMPT}`];
770
+ lines.push(`If codex mcp add registered ${serverName}, add this line under [mcp_servers.${serverName}] in ~/.codex/config.toml so Codex runs Hue's tool calls in parallel:`, ` ${CODEX_PARALLEL}`);
771
+ if (project)
772
+ lines.push(`If this connection returns HTTP 404, ${project} is not an active project this credential can reach. Change or remove --project; signing in again will not fix it.`);
773
+ const verify = project
774
+ ? `Use the Hue MCP: call list_projects and confirm it returns only the pinned project ${project} with connection.pinned true. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. Do not pass project_id: this connection is pinned.`
775
+ : MCP_VERIFY_PROMPT;
776
+ return [...lines, "Then ask your agent:", ` ${verify}`];
525
777
  }
526
778
  /**
527
779
  * Runs `hue mcp install` and returns the process exit code: 0 done or printed, 1 failed, 2 usage
@@ -578,6 +830,25 @@ export async function runMcpCommand(argv, io = {}) {
578
830
  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);
579
831
  if (parsed.values["read-only"])
580
832
  url = readOnlyMcpUrl(url);
833
+ // --project replaces a pin already in --url. Without it, validate and keep the URL's pin.
834
+ const urlProjects = new URL(url).searchParams.getAll("project");
835
+ let projectValue = parsed.values.project;
836
+ if (projectValue === undefined && urlProjects.length) {
837
+ const trimmed = urlProjects.map((value) => value.trim());
838
+ if (trimmed.some((value) => !value) || new Set(trimmed).size !== 1)
839
+ return fail("In --url: ?project= must name one project id or slug. Remove empty or conflicting values.", 2);
840
+ projectValue = trimmed[0];
841
+ }
842
+ let project;
843
+ let serverName = SERVER_NAME;
844
+ if (projectValue !== undefined) {
845
+ const selected = parseProject(projectValue);
846
+ if ("error" in selected)
847
+ return fail(`${parsed.values.project === undefined ? "In --url: " : ""}${selected.error}\n\n${MCP_USAGE}`, 2);
848
+ project = selected.project;
849
+ serverName = selected.serverName;
850
+ }
851
+ url = projectMcpUrl(url, project);
581
852
  // --toolsets wins over a selection already in --url, which wins over the client's default.
582
853
  // Both sources are validated: Hue ignores an unknown name and would list its default.
583
854
  const requested = parsed.values.toolsets ?? new URL(url).searchParams.get("toolsets");
@@ -589,12 +860,15 @@ export async function runMcpCommand(argv, io = {}) {
589
860
  toolsets = selected.toolsets;
590
861
  }
591
862
  url = toolsetsMcpUrl(url, toolsets);
592
- const plan = planFor(clientId, auth, scope, url);
863
+ const shownUrl = displayMcpUrl(url);
864
+ // What the command reports doing; a command a person is to run is printed as given.
865
+ const shownCommand = (command) => command.display.replaceAll(shellWord(url), () => shellWord(shownUrl));
866
+ const plan = planFor(clientId, auth, scope, url, serverName);
593
867
  if (parsed.values.print) {
594
868
  stdout.write(plan.snippet);
595
869
  return 0;
596
870
  }
597
- const steps = nextSteps(clientId, auth);
871
+ const steps = nextSteps(clientId, auth, serverName, project);
598
872
  if (plan.kind === "manual") {
599
873
  out(plan.hint);
600
874
  stdout.write(plan.snippet);
@@ -605,7 +879,7 @@ export async function runMcpCommand(argv, io = {}) {
605
879
  if (plan.kind === "cli") {
606
880
  if (parsed.values["dry-run"]) {
607
881
  for (const command of plan.commands)
608
- out(`Would run: ${command.display}`);
882
+ out(`Would run: ${shownCommand(command)}`);
609
883
  return 0;
610
884
  }
611
885
  let failed = false;
@@ -617,10 +891,10 @@ export async function runMcpCommand(argv, io = {}) {
617
891
  out(command.display);
618
892
  continue;
619
893
  }
620
- out(`Running: ${command.display}`);
894
+ out(`Running: ${shownCommand(command)}`);
621
895
  const code = await runClientCli(executable, command.args, { cwd, env, stdout, stderr });
622
896
  if (code === 0) {
623
- out(`Registered the "${SERVER_NAME}" MCP server (${url}) with ${command.label}.`);
897
+ out(`Registered the "${serverName}" MCP server (${shownUrl}) with ${command.label}.`);
624
898
  continue;
625
899
  }
626
900
  failed = true;
@@ -634,10 +908,14 @@ export async function runMcpCommand(argv, io = {}) {
634
908
  }
635
909
  const path = resolve(cwd, plan.file);
636
910
  const display = displayPath(cwd, path);
911
+ let merged;
637
912
  let content;
913
+ let read;
638
914
  try {
915
+ await rejectLinkedParents(cwd, path, display);
639
916
  const existing = await readJsonConfig(path, display);
640
- let merged = mergeServerEntry(existing, plan.key, plan.entry, display);
917
+ read = existing.file;
918
+ merged = mergeServerEntry(existing.config, plan.key, plan.entry, display, serverName);
641
919
  if (plan.input)
642
920
  merged = mergeVscodeInput(merged, plan.input, display);
643
921
  content = json(merged);
@@ -648,17 +926,21 @@ export async function runMcpCommand(argv, io = {}) {
648
926
  return fail(`Could not read ${display}: ${error.message}`);
649
927
  }
650
928
  if (parsed.values["dry-run"]) {
651
- out(`Would write ${display}:`);
652
- stdout.write(content);
929
+ // Other servers' entries can hold literal tokens; the file keeps them, the output does not.
930
+ const shown = json(redactConfig(merged, plan.key, url, shownUrl));
931
+ out(shown === content
932
+ ? `Would write ${display}:`
933
+ : `Would write ${display} (credential values shown as ${REDACTED}):`);
934
+ stdout.write(shown);
653
935
  return 0;
654
936
  }
655
937
  try {
656
- await writeConfigFile(path, content, display);
938
+ await writeConfigFile(cwd, path, content, display, read);
657
939
  }
658
940
  catch (error) {
659
941
  return fail(`Could not write ${display}: ${error.message}`);
660
942
  }
661
- out(`Wrote ${display} with the "${SERVER_NAME}" MCP server (${url}).`);
943
+ out(`Wrote ${display} with the "${serverName}" MCP server (${shownUrl}).`);
662
944
  for (const line of steps)
663
945
  out(line);
664
946
  return 0;