@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/CLI.md +81 -39
- package/README.md +4 -2
- package/dist/cli/eval.d.ts +4 -0
- package/dist/cli/eval.js +16 -11
- package/dist/cli/listen.d.ts +1 -1
- package/dist/cli/listen.js +54 -12
- package/dist/cli/mcp.d.ts +18 -7
- package/dist/cli/mcp.js +347 -65
- package/dist/evals/checkpoint.d.ts +5 -0
- package/dist/evals/checkpoint.js +17 -1
- package/dist/evals/client.d.ts +11 -5
- package/dist/evals/client.js +29 -12
- package/dist/evals/files.js +18 -10
- 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";
|
|
@@ -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
|
|
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
|
-
"
|
|
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] [--
|
|
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
|
|
89
|
-
project, traces,
|
|
90
|
-
|
|
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: { [
|
|
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
|
-
|
|
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 ${
|
|
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: { [
|
|
172
|
+
cursorJson: json({ mcpServers: { [serverName]: cursorServer } }),
|
|
160
173
|
codexCli: {
|
|
161
|
-
args: ["mcp", "add",
|
|
162
|
-
display: `codex mcp add ${
|
|
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.${
|
|
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: { [
|
|
180
|
+
vscodeJson: json({ servers: { [serverName]: vscodeServer }, inputs: [vscodeInput] }),
|
|
168
181
|
windsurfServer,
|
|
169
|
-
windsurfJson: json({ mcpServers: { [
|
|
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
|
-
|
|
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 ${
|
|
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=`
|
|
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
|
|
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: { [
|
|
218
|
+
claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
|
|
200
219
|
claudeCodeCli: {
|
|
201
|
-
args: ["mcp", "add", "--transport", "http", "--scope", "user",
|
|
202
|
-
display: `claude mcp add --transport http --scope user ${
|
|
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",
|
|
206
|
-
display: `codex mcp add ${
|
|
224
|
+
args: ["mcp", "add", serverName, "--url", url],
|
|
225
|
+
display: `codex mcp add ${serverName} --url ${shellWord(url)}`,
|
|
207
226
|
},
|
|
208
|
-
//
|
|
209
|
-
codexToml: `[mcp_servers.${
|
|
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
|
|
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 ?? {}), [
|
|
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
|
-
|
|
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
|
-
/**
|
|
310
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
524
|
-
|
|
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
|
|
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
|
|
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
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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 "${
|
|
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;
|