@hue-run/sdk 0.11.1 → 0.11.2

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 CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.11.1`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.11.2`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -168,12 +168,13 @@ accepted too, but Node 22 and 24 read that flag from the whole command line and
168
168
  The env file is written with mode `0600` through a temporary file and an atomic rename. Other
169
169
  lines are preserved; a symlink or a non-regular file is refused; an existing different value is
170
170
  replaced only with `--force`. Empty values, whitespace and URLs are refused before any request.
171
- The MCP endpoint is `https://mcp.hue.run/mcp` for `https://app.hue.run`,
172
- `https://mcp.staging.hue.run/mcp` for `https://staging.hue.run` and `<origin>/api/mcp` otherwise;
173
- plain HTTP origins are accepted for loopback test servers only. Output names variables and lengths
174
- (`Stored the key (NN chars) as HUE_API_KEY and HUE_MCP_KEY`), never values. When git does not ignore the env file, the command
175
- warns; `--gitignore` appends the file name to the `.gitignore` next to it. Exit codes: `0` stored,
176
- `1` failed, `2` usage error, `130` interrupted.
171
+ The production MCP server is available only at `https://mcp.hue.run/mcp`. `hue login` uses
172
+ `https://mcp.staging.hue.run/mcp` with `https://staging.hue.run` and `<origin>/api/mcp` for other
173
+ development or preview origins; plain HTTP origins are accepted for loopback test servers only.
174
+ Output names variables and lengths (`Stored the key (NN chars) as HUE_API_KEY and HUE_MCP_KEY`),
175
+ never values. When git does not ignore the env file, the command warns; `--gitignore` appends the
176
+ file name to the `.gitignore` next to it. Exit codes: `0` stored, `1` failed, `2` usage error, `130`
177
+ interrupted.
177
178
 
178
179
  ## Install the MCP for your coding agent
179
180
 
@@ -193,16 +194,26 @@ for staging). Hue's Settings page does not show these snippets; this command kee
193
194
  for `claude-code`, `codex` and `conductor`. Hue does not yet accept Cursor's sign-in callback, so
194
195
  `cursor` and the other clients use a key.
195
196
 
197
+ `--project <id-or-slug>` pins either kind of connection to one project. The command adds or
198
+ replaces `?project=<value>` while keeping `toolsets`, and names the server `hue-<value>` so it can
199
+ sit beside the organization-wide `hue` connection. A pinned connection's tools do not publish or
200
+ accept `project_id`; `list_projects` returns only that project with `connection.pinned: true`.
201
+ The printed sign-in Codex TOML keeps its URL bare and sends `X-Hue-MCP-Project` together with any
202
+ `X-Hue-MCP-Toolsets` header. An empty pin is refused. If the selected project is inactive or the
203
+ credential cannot reach it, Hue returns HTTP `404`; change or remove `--project`, because signing
204
+ in again does not change the credential's reach.
205
+
196
206
  `--read-only` adds `?read_only=true` to a key configuration's URL, so Hue hides and rejects write
197
207
  tools whatever the key allows. A project key, with or without it, keeps reaching its one project.
198
208
  With `--auth oauth` it is refused: a sign-in connection has **Read and write** access, so use a
199
209
  **Read** project key for read-only access.
200
210
 
201
- `--toolsets <names>` chooses the tools the connection lists: `all`, `observe` (the production
202
- reads: projects, traces, spans and their recorded content, sessions, trace checks, intents and
203
- documentation), or the groups `project`, `traces`, `evals`, `environments`, `intents` and `docs`,
204
- comma-separated. The selection is added to the URL as `?toolsets=<names>`, for a key or a sign-in
205
- configuration alike. Without `--toolsets`, `claude-code`, `codex` and `conductor` select `all`,
211
+ `--toolsets <names>` chooses the tools the connection lists: `all`; the profiles `observe`
212
+ (production reads), `author` (author evaluations) and `evaluate` (run an agent against a case);
213
+ or the groups `project`, `traces`, `eval_sets`, `runs`, `judges`, `cases`, `runners`,
214
+ `environments`, `intents` and `docs`, comma-separated. Each group holds at most 20 tools. The
215
+ selection is added to the URL as `?toolsets=<names>`, for a key or a sign-in configuration alike.
216
+ Without `--toolsets`, `claude-code`, `codex` and `conductor` select `all`,
206
217
  because those agents defer MCP tools behind their own tool search; `cursor`, which caps the tools
207
218
  it loads, selects `observe`; the other clients list Hue's default. The default is the production
208
219
  reads plus `search_hue_tools`, which finds every other tool the connection can call, and
@@ -212,6 +223,14 @@ with write access), so nothing is out of reach. `--toolsets` replaces a selectio
212
223
  passed on, because Hue ignores them and would list its default. `get_project_context` is always
213
224
  listed and reports the selection.
214
225
 
226
+ MCP arguments and results name ids after their product objects. Eval tools use `eval_set_id`,
227
+ `eval_set_version_id`, `from_eval_set_version_id` and `eval_set_case_id`; `evaluator_id`,
228
+ `evaluator_version_id` and `evaluator_version_ids`; `run_id` and `baseline_run_id`; and
229
+ `scoring_run_id` and `scoring_item_id`. Dispatches use `managed_run_id` and `local_run_id`,
230
+ reviewed cases `case_id`, environment versions `environment_version_id`, and trace-check versions
231
+ `trace_check_version_id` or `active_trace_check_version_id`. List results are named `eval_sets`,
232
+ `evaluators`, `runs`, `scoring_runs` and `cases`, while a row's own id remains `id`.
233
+
215
234
  | Client | Key (`--auth key`) | Sign-in (`--auth oauth`) |
216
235
  | --- | --- | --- |
217
236
  | `claude-code` | Merges `mcpServers.hue` into `./.mcp.json`. `--scope user` runs `claude mcp add --transport http --scope user hue URL --header 'Authorization: Bearer ${HUE_MCP_KEY}'` when `claude` is on `PATH`, otherwise prints it. | Merges `mcpServers.hue` with only `type` and `url` into `./.mcp.json`; `--scope user` runs `claude mcp add --transport http --scope user hue URL`. Then run `/mcp` in Claude Code, select `hue` and choose **Authenticate**. |
@@ -234,7 +253,8 @@ through a trace read) end to end; if Hue's tools do not load after signing in, r
234
253
  again with `--auth key`.
235
254
 
236
255
  JSON files are parsed and merged: other servers, inputs and top-level fields are kept, only the
237
- `hue` entry is replaced, and invalid JSON (including comments) is refused together with the snippet
256
+ selected server entry is replaced (`hue`, or `hue-<value>` with `--project`), and invalid JSON
257
+ (including comments) is refused together with the snippet
238
258
  to add by hand. Files are written with mode `0644` through a temporary file and an atomic rename;
239
259
  symlinks are refused. `--dry-run` prints the resulting file content or commands without writing or
240
260
  running; `--print` prints only the snippet. Client CLIs run without a shell, so the
@@ -246,7 +266,7 @@ the client (VS Code prompts for the key instead). A desktop app started from the
246
266
  does not see that shell's variables, and Conductor's agents read the login-shell environment that
247
267
  Conductor captures, so sign-in is the simpler choice there. After a sign-in installation it names
248
268
  the client's authentication action. Both end with the prompt that verifies the connection:
249
- `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.` `list_projects` returns a project key's one project too. Exit codes:
269
+ `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.` A pinned installation instead verifies `connection.pinned: true` and omits `project_id`. `list_projects` returns a project key's one project too. Exit codes:
250
270
  `0` done or printed, `1` failed, `2` usage error.
251
271
 
252
272
  ## Deliver simulated events to a local bot
@@ -255,12 +275,10 @@ the client's authentication action. Both end with the prompt that verifies the c
255
275
  agent such as a Slack bot can be tested without a public URL, the way `stripe listen --forward-to`
256
276
  delivers webhooks. Hue signs each request as the provider does (Slack Events API requests today);
257
277
  the command pulls them, forwards each one unchanged to `--forward-to` and reports the bot's answer
258
- to Hue, which settles or retries the event on the provider's schedule. It needs a **listen** event
259
- subscription on the world or on a connection key; the subscription's id and signing secret are shown
260
- once when it is created. Configure the bot with that signing secret where `SLACK_SIGNING_SECRET`
261
- went, then start the command: Hue offers the URL verification first, and events follow once the bot
262
- has answered it. Event subscriptions are not yet available on `https://app.hue.run`; until the
263
- origin offers them, the command stops with Hue's refusal.
278
+ to Hue. It needs a **listen** event subscription on the world or on a connection key, and the bot
279
+ configured with that subscription's signing secret where `SLACK_SIGNING_SECRET` went. Event
280
+ subscriptions are not yet available on `https://app.hue.run`; until the origin offers them, the
281
+ command exits `1` with the refusal it receives.
264
282
 
265
283
  ```sh
266
284
  hue listen --subscription <id> --forward-to http://localhost:3000/slack/events --env-path .env.world
@@ -269,8 +287,8 @@ The credential is the subscription's own, read from the environment (or `--env-p
269
287
  a variable already set keeps its value): `HUE_WORLD_TOKEN` for a world's subscription or
270
288
  `HUE_CONNECTION_KEY` for a connection key's, chosen with `--credential world-token|connection-key`
271
289
  when both are set. It is never taken from the command line, where other local processes and shell
272
- history can read it. A project key is refused: a value equal to `HUE_API_KEY` or `HUE_MCP_KEY` exits
273
- 2 before any request, and Hue answers any credential that is not the subscription's with `401`.
290
+ history can read it. A project key is refused: a value equal to `HUE_API_KEY`, `HUE_MCP_KEY`,
291
+ `HUE_PROJECT_KEY` or `HUE_SERVICE_KEY` exits 2 before any request.
274
292
  `--origin` (default `HUE_BASE_URL`, then `https://app.hue.run`) must be HTTPS, or plain HTTP on a
275
293
  loopback test server.
276
294
 
@@ -282,12 +300,13 @@ a later one is a timeout. A failing answer with `x-slack-no-retry: 1` is reporte
282
300
  retrying it. Only a successful URL verification answer's body (at most 4 KiB, for its challenge) is
283
301
  sent to Hue; any other answer body stays on this machine. Each pull waits for at most 20 seconds and
284
302
  leases up to `--max` deliveries (1 to 10, default 10), which are forwarded concurrently; the next
285
- pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A provider
286
- retry of an event is a new delivery and is forwarded again with its `X-Slack-Retry-Num` and
303
+ pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A pull
304
+ that answers at once with nothing new to forward is not repeated for a second. A provider retry of
305
+ an event is a new delivery and is forwarded again with its `X-Slack-Retry-Num` and
287
306
  `X-Slack-Retry-Reason`, so the bot deduplicates by `event_id` as it does with Slack. A delivery Hue
288
307
  hands out a second time is not sent again; its recorded answer is repeated. An acknowledgement Hue
289
- did not answer is repeated until the delivery's lease ends, as Hue stated it (30 seconds from the
290
- lease).
308
+ did not answer is repeated until the delivery's lease ends, as the pull's answer stated it, and for
309
+ at most 30 seconds.
291
310
 
292
311
  `--forward-to` must name this machine: `localhost`, a `.localhost` name or a loopback address, and a
293
312
  name is refused unless every address it resolves to is a loopback address. `--allow-remote-forward`
@@ -298,15 +317,16 @@ bot's status and duration, and the state Hue recorded), never a body, a signatur
298
317
  credentials in error messages are replaced with `[redacted]`.
299
318
 
300
319
  Ctrl+C or `SIGTERM` stops pulling (a waiting pull is cancelled), finishes the deliveries in flight
301
- and acknowledges them, then exits `0`; a second Ctrl+C abandons the forwards and acknowledgements
302
- still in flight and exits `130`. Nothing is acknowledged before the bot answered or its window
303
- closed, and a lease left unacknowledged lapses into a timeout that Hue retries on the provider's
304
- schedule, so no event is lost and none is marked delivered unanswered. Network errors, `429`, `5xx`
320
+ and acknowledges them, then exits `0`, no sooner than a second after the first Ctrl+C. A second
321
+ Ctrl+C a second or more after the first abandons the forwards and acknowledgements still in flight
322
+ and exits `130`; one within a second of the first, such as the copy `npx` forwards to the command,
323
+ is the same stop. Nothing is acknowledged before the bot answered or its window closed; a delivery
324
+ left unacknowledged is left to its lease. Network errors, `429`, `5xx`, an unreadable pull answer
305
325
  and another open pull for the same subscription (a second `hue listen`) are retried with backoff up
306
326
  to 30 seconds; a `Retry-After` can lengthen a wait to at most 60 seconds, never shorten it. Exit
307
327
  codes: `0` stopped, `1` Hue refused the credential, the subscription (unknown, revoked, or one that
308
- delivers to a request URL) or the pull itself (a redirect, another refusal or an unreadable answer),
309
- `2` usage error, `130` interrupted twice.
328
+ delivers to a request URL) or the pull itself (a redirect, another refusal or an answer without
329
+ deliveries), `2` usage error, `130` interrupted twice.
310
330
 
311
331
  ## Local state and conflicts
312
332
 
@@ -412,7 +432,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
412
432
  # Set project to an existing supported fixture; use the same directory on resume.
413
433
  project=/absolute/path/to/supported-fixture
414
434
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
415
- --archive .artifacts/typescript/hue-run-sdk-0.11.1.tgz \
435
+ --archive .artifacts/typescript/hue-run-sdk-0.11.2.tgz \
416
436
  --origin https://STAGING_ORIGIN \
417
437
  --project "$project" --command setup \
418
438
  --evidence .context/setup-staging-before-claim.json
@@ -423,7 +443,7 @@ the private local handoff and finish the real browser claim, then reconcile the
423
443
 
424
444
  ```sh
425
445
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
426
- --archive .artifacts/typescript/hue-run-sdk-0.11.1.tgz \
446
+ --archive .artifacts/typescript/hue-run-sdk-0.11.2.tgz \
427
447
  --origin https://STAGING_ORIGIN \
428
448
  --project "$project" --command claim \
429
449
  --evidence .context/setup-staging-after-claim.json
@@ -27,6 +27,10 @@ export interface Output {
27
27
  /** Diagnostics; always stderr. */
28
28
  error(line: string): void;
29
29
  }
30
+ /** Agent key from the adapter filename, or the first script-like token of a command. An
31
+ * interpreter is skipped with a Windows executable suffix too (`node.exe`, and `bun.exe`, as npm
32
+ * installs Bun on every platform). */
33
+ export declare function derivedAgentKey(adapterFile: string | undefined, command: string | undefined): string;
30
34
  /**
31
35
  * Spawns the agent command once in its own process group and returns its trimmed stdout. A
32
36
  * timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
package/dist/cli/eval.js CHANGED
@@ -179,15 +179,16 @@ const INTERPRETERS = new Set([
179
179
  "zsh",
180
180
  "env",
181
181
  ]);
182
- /** Agent key from the adapter filename, or the first script-like token of a command. */
183
- function derivedAgentKey(adapterFile, command) {
182
+ /** Agent key from the adapter filename, or the first script-like token of a command. An
183
+ * interpreter is skipped with a Windows executable suffix too (`node.exe`, and `bun.exe`, as npm
184
+ * installs Bun on every platform). */
185
+ export function derivedAgentKey(adapterFile, command) {
184
186
  if (adapterFile)
185
187
  return slug(basename(adapterFile, extname(adapterFile)));
186
188
  const tokens = (command ?? "").trim().split(/\s+/);
187
189
  const names = tokens.map((token) => token.split(/[\\/]/).pop() ?? "");
188
- const script = names.find((name) => name && !name.startsWith("-") && !INTERPRETERS.has(name.toLowerCase())) ??
189
- names[0] ??
190
- "";
190
+ const interpreter = (name) => INTERPRETERS.has(name.toLowerCase().replace(/\.(?:exe|cmd|bat)$/, ""));
191
+ const script = names.find((name) => name && !name.startsWith("-") && !interpreter(name)) ?? names[0] ?? "";
191
192
  return slug(basename(script, extname(script)));
192
193
  }
193
194
  function defaultRunName(agent) {
@@ -23,7 +23,7 @@ export interface ListenCommandIo {
23
23
  /** Delays between retries of a failed pull. */
24
24
  retryDelaysMs?: readonly number[];
25
25
  }
26
- export declare const LISTEN_USAGE = "Usage: hue listen --subscription ID --forward-to URL [--origin URL] [--env-path PATH]\n [--credential world-token|connection-key] [--max N] [--allow-remote-forward]\n\nDeliver a simulated world's events to a receiver on this machine. Hue's signed requests (Slack\nEvents API requests today) are pulled with the subscription's own credential, forwarded unchanged\nto --forward-to, and acknowledged with the receiver's answer. Press Ctrl+C to stop: pulls stop,\ndeliveries in flight are finished and acknowledged, and nothing unanswered is acknowledged.\n\nThe credential is read from the environment, never from the command line:\n HUE_WORLD_TOKEN the world token, for a subscription on one world\n HUE_CONNECTION_KEY the connection key, for a subscription on a connection key\nA project key (HUE_API_KEY, HUE_MCP_KEY) is refused.\n\nOptions:\n --subscription ID The listen subscription to deliver (its id from Hue)\n --forward-to URL The local receiver, for example http://localhost:3000/slack/events\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists)\n --credential KIND world-token or connection-key, when both variables are set\n --max N Deliveries leased per pull and forwarded at once, 1 to 10 (default 10)\n --allow-remote-forward Allow a --forward-to host other than this machine, over https only\n -h, --help Show this help";
26
+ export declare const LISTEN_USAGE = "Usage: hue listen --subscription ID --forward-to URL [--origin URL] [--env-path PATH]\n [--credential world-token|connection-key] [--max N] [--allow-remote-forward]\n\nDeliver a simulated world's events to a receiver on this machine. Hue's signed requests (Slack\nEvents API requests today) are pulled with the subscription's own credential, forwarded unchanged\nto --forward-to, and acknowledged with the receiver's answer. Press Ctrl+C to stop: pulls stop,\ndeliveries in flight are finished and acknowledged, and nothing unanswered is acknowledged.\n\nThe credential is read from the environment, never from the command line:\n HUE_WORLD_TOKEN the world token, for a subscription on one world\n HUE_CONNECTION_KEY the connection key, for a subscription on a connection key\nA project key (HUE_API_KEY, HUE_MCP_KEY, HUE_PROJECT_KEY or HUE_SERVICE_KEY) is refused.\n\nOptions:\n --subscription ID The listen subscription to deliver (its id from Hue)\n --forward-to URL The local receiver, for example http://localhost:3000/slack/events\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists)\n --credential KIND world-token or connection-key, when both variables are set\n --max N Deliveries leased per pull and forwarded at once, 1 to 10 (default 10)\n --allow-remote-forward Allow a --forward-to host other than this machine, over https only\n -h, --help Show this help";
27
27
  /** A leased delivery: the exact request the provider would send. */
28
28
  interface Delivery {
29
29
  deliveryId: string;
@@ -5,6 +5,7 @@ import { request as httpsRequest } from "node:https";
5
5
  import { isIP } from "node:net";
6
6
  import { resolve } from "node:path";
7
7
  import { parseArgs, parseEnv } from "node:util";
8
+ import { HUE_CONTROL_PLANE_VARIABLES } from "../environment/world.js";
8
9
  import { scrubCredentialText } from "../tool-definitions.js";
9
10
  import { sdkVersion } from "../version.js";
10
11
  import { envFileArgument, envFileOptions } from "./env-file.js";
@@ -32,6 +33,11 @@ const ACK_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
32
33
  const MAX_RETRY_AFTER_MS = 60_000;
33
34
  /** Deliveries whose answer is remembered, so a delivery Hue hands out again is not re-sent. */
34
35
  const REMEMBERED_DELIVERIES = 1_024;
36
+ /** The least time between pulls that forwarded nothing new, so repeats cannot spin. */
37
+ const IDLE_PULL_FLOOR_MS = 1_000;
38
+ /** A stop request this soon after the first is the same one: under `npx`, npm forwards the
39
+ * terminal's SIGINT to a process group that already received it. */
40
+ const REPEATED_STOP_MS = 1_000;
35
41
  /** Connection-level headers the local request sets for itself. */
36
42
  const HOP_BY_HOP = new Set([
37
43
  "host",
@@ -64,7 +70,7 @@ deliveries in flight are finished and acknowledged, and nothing unanswered is ac
64
70
  The credential is read from the environment, never from the command line:
65
71
  HUE_WORLD_TOKEN the world token, for a subscription on one world
66
72
  HUE_CONNECTION_KEY the connection key, for a subscription on a connection key
67
- A project key (HUE_API_KEY, HUE_MCP_KEY) is refused.
73
+ A project key (HUE_API_KEY, HUE_MCP_KEY, HUE_PROJECT_KEY or HUE_SERVICE_KEY) is refused.
68
74
 
69
75
  Options:
70
76
  --subscription ID The listen subscription to deliver (its id from Hue)
@@ -413,7 +419,7 @@ function selectCredential(env, choice) {
413
419
  const credential = kind === "world-token" ? worldToken : connectionKey;
414
420
  if (!credential)
415
421
  throw new UsageError(`${variable} is not set`);
416
- for (const projectVariable of ["HUE_API_KEY", "HUE_MCP_KEY"])
422
+ for (const projectVariable of HUE_CONTROL_PLANE_VARIABLES)
417
423
  if (env[projectVariable]?.trim() === credential)
418
424
  throw new UsageError(`${variable} holds the project key in ${projectVariable}; hue listen pulls only with the subscription's world token or connection key`);
419
425
  if (kind === "world-token" && !WORLD_TOKEN.test(credential))
@@ -547,16 +553,18 @@ export async function runListenCommand(argv, io = {}) {
547
553
  accept: "application/json",
548
554
  "user-agent": `hue-sdk-typescript/${sdkVersion} hue-listen`,
549
555
  };
550
- // The first stop request stops pulling and lets deliveries in flight finish; a second one
551
- // abandons their acknowledgements, whose leases then lapse into timeouts.
556
+ // The first stop request stops pulling and lets deliveries in flight finish; a second one, a
557
+ // second or more later, abandons their acknowledgements, whose leases then lapse into timeouts.
552
558
  const stopping = new AbortController();
553
559
  const forced = new AbortController();
560
+ let stoppedAt = 0;
554
561
  const removeInterrupt = (io.onInterrupt ?? defaultOnInterrupt)(() => {
555
562
  if (!stopping.signal.aborted) {
563
+ stoppedAt = performance.now();
556
564
  stopping.abort();
557
565
  warn("Stopping: finishing deliveries in flight (press Ctrl+C again to abandon them).");
558
566
  }
559
- else
567
+ else if (performance.now() - stoppedAt >= REPEATED_STOP_MS)
560
568
  forced.abort();
561
569
  });
562
570
  const remembered = new Map();
@@ -736,6 +744,7 @@ export async function runListenCommand(argv, io = {}) {
736
744
  }
737
745
  }
738
746
  };
747
+ /** Forwards and acknowledges one delivery; true when its request reached the receiver now. */
739
748
  const deliver = async (delivery, deadline) => {
740
749
  // Hue may hand out a delivery again (a pull whose answer was retried); its request already
741
750
  // reached the receiver, so the recorded answer is repeated and nothing is sent twice.
@@ -751,7 +760,7 @@ export async function runListenCommand(argv, io = {}) {
751
760
  : `${delivery.eventId ?? "event"}${delivery.retryNum ? ` retry ${delivery.retryNum}` : ""}`;
752
761
  if (answer === null) {
753
762
  out(`${new Date().toISOString()} ${name} -> abandoned; not acknowledged, the lease lapses into a timeout`);
754
- return;
763
+ return false;
755
764
  }
756
765
  if (!previous) {
757
766
  remembered.set(delivery.deliveryId, answer);
@@ -762,6 +771,7 @@ export async function runListenCommand(argv, io = {}) {
762
771
  if (result.kind === "revoked")
763
772
  fatal ??= `Subscription ${subscription} is revoked.`;
764
773
  out(`${new Date().toISOString()} ${name}${previous ? " (repeated)" : ""} -> ${describeAnswer(answer)}; ${describeAck(result)}`);
774
+ return !previous;
765
775
  };
766
776
  out(`Forwarding subscription ${subscription} from ${foreign(origin)} to ${foreign(target.href)} using the ${label}. Press Ctrl+C to stop.`);
767
777
  let failures = 0;
@@ -786,9 +796,7 @@ export async function runListenCommand(argv, io = {}) {
786
796
  continue;
787
797
  }
788
798
  failures = 0;
789
- // A pull that answers empty at once, though it could wait, is not repeated at once.
790
- if (ready && result.deliveries.length === 0 && performance.now() - pulledAt < 1_000)
791
- await sleep(1_000, stopping.signal);
799
+ const waited = ready;
792
800
  if (!ready) {
793
801
  ready = true;
794
802
  out("Ready: waiting for events.");
@@ -797,11 +805,26 @@ export async function runListenCommand(argv, io = {}) {
797
805
  warn(`Skipped ${result.skipped} delivery(ies) this version of @hue-run/sdk cannot forward; their leases lapse and Hue retries them.`);
798
806
  if (result.excess)
799
807
  warn(`Hue leased ${result.excess} more delivery(ies) than --max ${max}; they are not forwarded, their leases lapse and Hue retries them.`);
800
- await Promise.all(result.deliveries.map((delivery) => deliver(delivery, result.receivedAt + result.leaseLeftMs(delivery))));
808
+ const forwarded = await Promise.all(result.deliveries.map((delivery) => deliver(delivery, result.receivedAt + result.leaseLeftMs(delivery))));
809
+ // A pull that could wait but answered at once with nothing new to forward (none due, only
810
+ // deliveries already answered, or ones this client skips) is not repeated at once, so a
811
+ // Hue that hands them out again or refuses their acknowledgements is not pulled in a loop.
812
+ if (waited && !forwarded.includes(true) && performance.now() - pulledAt < IDLE_PULL_FLOOR_MS)
813
+ await sleep(IDLE_PULL_FLOOR_MS, stopping.signal);
801
814
  }
802
815
  }
803
816
  finally {
804
- removeInterrupt();
817
+ // A repeated stop request can still arrive once the loop has ended (npm forwards Ctrl+C a few
818
+ // milliseconds late), and without a listener, or once the process has begun to exit, the
819
+ // runtime's default handler would end it by SIGINT. So the listener, and the process, stay
820
+ // until such a request would count as a new one: at most a second after the first.
821
+ const repeatable = stopping.signal.aborted
822
+ ? REPEATED_STOP_MS - (performance.now() - stoppedAt)
823
+ : 0;
824
+ if (repeatable > 0)
825
+ setTimeout(removeInterrupt, repeatable);
826
+ else
827
+ removeInterrupt();
805
828
  }
806
829
  if (fatal !== null) {
807
830
  warn(`Error: ${fatal}`);
package/dist/cli/mcp.d.ts CHANGED
@@ -20,15 +20,17 @@ export declare const DEFAULT_MCP_URL = "https://mcp.hue.run/mcp";
20
20
  * lists them, after which each call takes `project_id`. It reads what needs attention and falls
21
21
  * back to recent traces, so a project without errors still proves the read path.
22
22
  */
23
- export declare 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.";
24
- /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and the `observe` profile. */
25
- export declare const MCP_TOOLSETS: readonly ["all", "observe", "project", "traces", "evals", "environments", "intents", "docs"];
23
+ export declare 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.";
24
+ /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and its curated profiles. */
25
+ export declare const MCP_TOOLSETS: readonly ["all", "observe", "author", "evaluate", "project", "traces", "eval_sets", "runs", "judges", "cases", "runners", "environments", "intents", "docs"];
26
26
  /**
27
27
  * The header form of `?toolsets=`. Only the sign-in Codex TOML uses it, as Hue's guide does, to
28
28
  * keep that URL bare; every other configuration carries the selection in its URL.
29
29
  */
30
30
  export declare const TOOLSETS_HEADER = "X-Hue-MCP-Toolsets";
31
- export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A key configuration references the\nHUE_MCP_KEY environment variable; a key value is never written. A sign-in configuration holds\nonly the URL: the client opens Hue in a browser, where you approve access to the projects of\none organization.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE key: reference HUE_MCP_KEY (the default, except for conductor)\n oauth: URL only, sign in with Hue in the client (claude-code, codex and\n conductor; the default for conductor)\n --read-only key only: add ?read_only=true to the URL so write tools are hidden. A\n sign-in connection has Read and write access; use a Read key for read-only\n --toolsets NAMES\n Tools to list, comma-separated: all, observe (production reads),\n project, traces, evals, environments, intents or docs, added to the\n URL as ?toolsets=. Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.";
31
+ /** The header form of `?project=`, used by the sign-in Codex TOML to keep its URL bare. */
32
+ export declare const PROJECT_HEADER = "X-Hue-MCP-Project";
33
+ export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]\n [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A key configuration references the\nHUE_MCP_KEY environment variable; a key value is never written. A sign-in configuration holds\nonly the URL: the client opens Hue in a browser, where you approve access to the projects of\none organization.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE key: reference HUE_MCP_KEY (the default, except for conductor)\n oauth: URL only, sign in with Hue in the client (claude-code, codex and\n conductor; the default for conductor)\n --read-only key only: add ?read_only=true to the URL so write tools are hidden. A\n sign-in connection has Read and write access; use a Read key for read-only\n --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,\n and the server is named hue-<value> so it can coexist with an unpinned hue\n --toolsets NAMES\n Tools to list, comma-separated: all; the observe, author or evaluate\n profiles; or project, traces, eval_sets, runs, judges, cases, runners,\n environments, intents or docs. Added to the URL as ?toolsets=.\n Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.";
32
34
  /**
33
35
  * A URL as a POSIX shell word for printed commands: `?` is a zsh glob and `&` ends a command, so a
34
36
  * URL with a query (such as `?read_only=true`) is single-quoted. Executed commands pass argv.
@@ -38,7 +40,7 @@ export declare function shellWord(value: string): string;
38
40
  * Canonical Hue client snippets for a key configuration; the JSON values are also the merge
39
41
  * entries for config files.
40
42
  */
41
- export declare function renderMcpSnippets(url: string): {
43
+ export declare function renderMcpSnippets(url: string, serverName?: string): {
42
44
  claudeCodeServer: {
43
45
  type: string;
44
46
  url: string;
@@ -92,9 +94,9 @@ export declare function renderMcpSnippets(url: string): {
92
94
  /**
93
95
  * Sign-in snippets: the server URL and nothing secret. The client discovers Hue's OAuth metadata
94
96
  * from it and sends it, query included, on every request; the token's resource ignores the query,
95
- * so `?toolsets=` selects tools here as it does for a key.
97
+ * so `?toolsets=` and `?project=` select tools and a project here as they do for a key.
96
98
  */
97
- export declare function renderMcpSignInSnippets(url: string): {
99
+ export declare function renderMcpSignInSnippets(url: string, serverName?: string): {
98
100
  claudeCodeServer: {
99
101
  type: string;
100
102
  url: string;
@@ -124,6 +126,15 @@ export declare function parseToolsets(value: string): {
124
126
  * parameter is removed, so the connection lists Hue's default.
125
127
  */
126
128
  export declare function toolsetsMcpUrl(url: string, toolsets: string | undefined): string;
129
+ /** Parses the id or slug used to pin a connection and derives its coexisting server name. */
130
+ export declare function parseProject(value: string): {
131
+ project: string;
132
+ serverName: string;
133
+ } | {
134
+ error: string;
135
+ };
136
+ /** Replaces `?project=` while preserving the URL's other selections. */
137
+ export declare function projectMcpUrl(url: string, project: string | undefined): string;
127
138
  /** `?read_only=true` hides and rejects Hue's write tools for any key. */
128
139
  export declare function readOnlyMcpUrl(url: string): string;
129
140
  /** Validates the MCP endpoint: HTTPS, or HTTP for loopback test servers; no credentials or hash. */
package/dist/cli/mcp.js CHANGED
@@ -16,7 +16,7 @@ const MAX_CONFIG_BYTES = 1024 * 1024;
16
16
  * lists them, after which each call takes `project_id`. It reads what needs attention and falls
17
17
  * back to recent traces, so a project without errors still proves the read path.
18
18
  */
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.";
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 or slug as project_id on each call after list_projects.";
20
20
  const CLIENT_IDS = [
21
21
  "claude-code",
22
22
  "codex",
@@ -37,13 +37,19 @@ const CLIENT_LABELS = {
37
37
  };
38
38
  /** Clients whose browser sign-in with Hue works against the deployed server. */
39
39
  const OAUTH_CLIENTS = ["claude-code", "codex", "conductor"];
40
- /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and the `observe` profile. */
40
+ /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and its curated profiles. */
41
41
  export const MCP_TOOLSETS = [
42
42
  "all",
43
43
  "observe",
44
+ "author",
45
+ "evaluate",
44
46
  "project",
45
47
  "traces",
46
- "evals",
48
+ "eval_sets",
49
+ "runs",
50
+ "judges",
51
+ "cases",
52
+ "runners",
47
53
  "environments",
48
54
  "intents",
49
55
  "docs",
@@ -53,6 +59,8 @@ export const MCP_TOOLSETS = [
53
59
  * keep that URL bare; every other configuration carries the selection in its URL.
54
60
  */
55
61
  export const TOOLSETS_HEADER = "X-Hue-MCP-Toolsets";
62
+ /** The header form of `?project=`, used by the sign-in Codex TOML to keep its URL bare. */
63
+ export const PROJECT_HEADER = "X-Hue-MCP-Project";
56
64
  /**
57
65
  * Toolsets a client's configuration selects unless `--toolsets` says otherwise. Claude Code,
58
66
  * Codex and Conductor's agents defer MCP tools behind their own tool search, so listing every tool
@@ -69,7 +77,8 @@ const DEFAULT_TOOLSETS = {
69
77
  /** Codex runs a server's tool calls one at a time without it; each Hue call is independent. */
70
78
  const CODEX_PARALLEL = "supports_parallel_tool_calls = true";
71
79
  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]
80
+ [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]
81
+ [--toolsets NAMES] [--url URL]
73
82
  [--scope project|user] [--dry-run] [--print]
74
83
 
75
84
  Configure a coding agent to use the Hue MCP server. A key configuration references the
@@ -84,10 +93,13 @@ Options:
84
93
  conductor; the default for conductor)
85
94
  --read-only key only: add ?read_only=true to the URL so write tools are hidden. A
86
95
  sign-in connection has Read and write access; use a Read key for read-only
96
+ --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,
97
+ and the server is named hue-<value> so it can coexist with an unpinned hue
87
98
  --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,
99
+ Tools to list, comma-separated: all; the observe, author or evaluate
100
+ profiles; or project, traces, eval_sets, runs, judges, cases, runners,
101
+ environments, intents or docs. Added to the URL as ?toolsets=.
102
+ Defaults: all for claude-code, codex and conductor,
91
103
  which search their own tools; observe for cursor. Without a selection
92
104
  Hue lists the production reads and search_hue_tools, which reaches
93
105
  every other tool
@@ -114,7 +126,7 @@ export function shellWord(value) {
114
126
  * Canonical Hue client snippets for a key configuration; the JSON values are also the merge
115
127
  * entries for config files.
116
128
  */
117
- export function renderMcpSnippets(url) {
129
+ export function renderMcpSnippets(url, serverName = SERVER_NAME) {
118
130
  const bearer = (reference) => `Bearer ${reference}`;
119
131
  const claudeCodeServer = {
120
132
  type: "http",
@@ -139,7 +151,7 @@ export function renderMcpSnippets(url) {
139
151
  };
140
152
  return {
141
153
  claudeCodeServer,
142
- claudeCodeProjectJson: json({ mcpServers: { [SERVER_NAME]: claudeCodeServer } }),
154
+ claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
143
155
  claudeCodeCli: {
144
156
  args: [
145
157
  "mcp",
@@ -148,25 +160,25 @@ export function renderMcpSnippets(url) {
148
160
  "http",
149
161
  "--scope",
150
162
  "user",
151
- SERVER_NAME,
163
+ serverName,
152
164
  url,
153
165
  "--header",
154
166
  `Authorization: Bearer \${${ENV_VAR}}`,
155
167
  ],
156
- display: `claude mcp add --transport http --scope user ${SERVER_NAME} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
168
+ display: `claude mcp add --transport http --scope user ${serverName} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
157
169
  },
158
170
  cursorServer,
159
- cursorJson: json({ mcpServers: { [SERVER_NAME]: cursorServer } }),
171
+ cursorJson: json({ mcpServers: { [serverName]: cursorServer } }),
160
172
  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}`,
173
+ args: ["mcp", "add", serverName, "--url", url, "--bearer-token-env-var", ENV_VAR],
174
+ display: `codex mcp add ${serverName} --url ${shellWord(url)} --bearer-token-env-var ${ENV_VAR}`,
163
175
  },
164
- codexToml: `[mcp_servers.${SERVER_NAME}]\nurl = "${url}"\nbearer_token_env_var = "${ENV_VAR}"\n${CODEX_PARALLEL}\n`,
176
+ codexToml: `[mcp_servers.${serverName}]\nurl = "${url}"\nbearer_token_env_var = "${ENV_VAR}"\n${CODEX_PARALLEL}\n`,
165
177
  vscodeServer,
166
178
  vscodeInput,
167
- vscodeJson: json({ servers: { [SERVER_NAME]: vscodeServer }, inputs: [vscodeInput] }),
179
+ vscodeJson: json({ servers: { [serverName]: vscodeServer }, inputs: [vscodeInput] }),
168
180
  windsurfServer,
169
- windsurfJson: json({ mcpServers: { [SERVER_NAME]: windsurfServer } }),
181
+ windsurfJson: json({ mcpServers: { [serverName]: windsurfServer } }),
170
182
  geminiCli: {
171
183
  args: [
172
184
  "mcp",
@@ -175,38 +187,44 @@ export function renderMcpSnippets(url) {
175
187
  "user",
176
188
  "--transport",
177
189
  "http",
178
- SERVER_NAME,
190
+ serverName,
179
191
  url,
180
192
  "--header",
181
193
  `Authorization: Bearer \${${ENV_VAR}}`,
182
194
  ],
183
195
  // Single quotes keep the reference literal when a person runs this in a shell where the
184
196
  // 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}}'`,
197
+ display: `gemini mcp add --scope user --transport http ${serverName} ${shellWord(url)} --header 'Authorization: Bearer \${${ENV_VAR}}'`,
186
198
  },
187
199
  };
188
200
  }
189
201
  /**
190
202
  * Sign-in snippets: the server URL and nothing secret. The client discovers Hue's OAuth metadata
191
203
  * 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.
204
+ * so `?toolsets=` and `?project=` select tools and a project here as they do for a key.
193
205
  */
194
- export function renderMcpSignInSnippets(url) {
195
- const toolsets = new URL(url).searchParams.get("toolsets");
206
+ export function renderMcpSignInSnippets(url, serverName = SERVER_NAME) {
207
+ const parsed = new URL(url);
208
+ const toolsets = parsed.searchParams.get("toolsets");
209
+ const project = parsed.searchParams.get("project");
210
+ const headers = [
211
+ toolsets ? `"${TOOLSETS_HEADER}" = "${toolsets}"` : null,
212
+ project ? `"${PROJECT_HEADER}" = "${project}"` : null,
213
+ ].filter((value) => value !== null);
196
214
  const claudeCodeServer = { type: "http", url };
197
215
  return {
198
216
  claudeCodeServer,
199
- claudeCodeProjectJson: json({ mcpServers: { [SERVER_NAME]: claudeCodeServer } }),
217
+ claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
200
218
  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)}`,
219
+ args: ["mcp", "add", "--transport", "http", "--scope", "user", serverName, url],
220
+ display: `claude mcp add --transport http --scope user ${serverName} ${shellWord(url)}`,
203
221
  },
204
222
  codexCli: {
205
- args: ["mcp", "add", SERVER_NAME, "--url", url],
206
- display: `codex mcp add ${SERVER_NAME} --url ${shellWord(url)}`,
223
+ args: ["mcp", "add", serverName, "--url", url],
224
+ display: `codex mcp add ${serverName} --url ${shellWord(url)}`,
207
225
  },
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`,
226
+ // Header forms of the selections, for a TOML entry that keeps its URL bare.
227
+ codexToml: `[mcp_servers.${serverName}]\nurl = "${projectMcpUrl(toolsetsMcpUrl(url, undefined), undefined)}"\n${headers.length ? `http_headers = { ${headers.join(", ")} }\n` : ""}${CODEX_PARALLEL}\n`,
210
228
  };
211
229
  }
212
230
  /**
@@ -237,6 +255,25 @@ export function toolsetsMcpUrl(url, toolsets) {
237
255
  parsed.search = `${query ? `${query}&` : ""}toolsets=${toolsets}`;
238
256
  return parsed.href;
239
257
  }
258
+ /** Parses the id or slug used to pin a connection and derives its coexisting server name. */
259
+ export function parseProject(value) {
260
+ const project = value.trim();
261
+ if (!project)
262
+ return { error: "--project is empty. Name a project id or slug, or remove the option." };
263
+ if (project.length > 256 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/iu.test(project))
264
+ return {
265
+ error: "--project takes a project id or slug, using letters, numbers and hyphens.",
266
+ };
267
+ return { project, serverName: `${SERVER_NAME}-${project.toLowerCase()}` };
268
+ }
269
+ /** Replaces `?project=` while preserving the URL's other selections. */
270
+ export function projectMcpUrl(url, project) {
271
+ const parsed = new URL(url);
272
+ parsed.searchParams.delete("project");
273
+ if (project)
274
+ parsed.searchParams.append("project", project);
275
+ return parsed.href;
276
+ }
240
277
  /** `?read_only=true` hides and rejects Hue's write tools for any key. */
241
278
  export function readOnlyMcpUrl(url) {
242
279
  const parsed = new URL(url);
@@ -252,15 +289,15 @@ class ConfigError extends Error {
252
289
  function isRecord(value) {
253
290
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
291
  }
255
- /** Replaces only the `hue` entry under `key`, keeping every other server and top-level field. */
256
- function mergeServerEntry(existing, key, entry, display) {
292
+ /** Replaces only the selected server entry under `key`, keeping every other server and field. */
293
+ function mergeServerEntry(existing, key, entry, display, serverName = SERVER_NAME) {
257
294
  if (existing !== undefined && !isRecord(existing))
258
295
  throw new ConfigError(`${display} must contain a JSON object.`);
259
296
  const root = existing ?? {};
260
297
  const servers = root[key];
261
298
  if (servers !== undefined && !isRecord(servers))
262
299
  throw new ConfigError(`${display}: "${key}" must be a JSON object.`);
263
- return { ...root, [key]: { ...(servers ?? {}), [SERVER_NAME]: entry } };
300
+ return { ...root, [key]: { ...(servers ?? {}), [serverName]: entry } };
264
301
  }
265
302
  function mergeVscodeInput(root, input, display) {
266
303
  const inputs = root.inputs;
@@ -398,6 +435,7 @@ function parseMcpArguments(argv) {
398
435
  client: { type: "string" },
399
436
  auth: { type: "string" },
400
437
  "read-only": { type: "boolean", default: false },
438
+ project: { type: "string" },
401
439
  toolsets: { type: "string" },
402
440
  url: { type: "string" },
403
441
  scope: { type: "string" },
@@ -413,10 +451,10 @@ function displayPath(cwd, path) {
413
451
  }
414
452
  const notOnPath = (executable, label) => `${executable} is not on PATH. Run this where ${label} is installed:`;
415
453
  /** `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);
454
+ function planFor(client, auth, scope, url, serverName) {
455
+ const snippets = renderMcpSnippets(url, serverName);
418
456
  // Claude Code and Codex take the same plan shape with or without a key.
419
- const shared = auth === "oauth" ? renderMcpSignInSnippets(url) : snippets;
457
+ const shared = auth === "oauth" ? renderMcpSignInSnippets(url, serverName) : snippets;
420
458
  const claudeUser = {
421
459
  executable: "claude",
422
460
  label: "Claude Code",
@@ -487,21 +525,23 @@ function planFor(client, auth, scope, url) {
487
525
  }
488
526
  }
489
527
  }
490
- function nextSteps(client, auth) {
528
+ function nextSteps(client, auth, serverName, project) {
491
529
  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.";
530
+ const approve = project
531
+ ? `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 you choose, within your role; for read-only access, use a Read project key with --auth key.";
493
533
  let lines;
494
534
  if (auth === "oauth")
495
535
  lines =
496
536
  client === "claude-code"
497
- ? [`In Claude Code, run /mcp, select ${SERVER_NAME} and choose Authenticate. ${approve}`]
537
+ ? [`In Claude Code, run /mcp, select ${serverName} and choose Authenticate. ${approve}`]
498
538
  : client === "codex"
499
539
  ? [
500
- `If Codex did not open Hue in your browser, run: codex mcp login ${SERVER_NAME}`,
540
+ `If Codex did not open Hue in your browser, run: codex mcp login ${serverName}`,
501
541
  approve,
502
542
  ]
503
543
  : [
504
- `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${SERVER_NAME}'s authentication action. ${approve}`,
544
+ `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${serverName}'s authentication action. ${approve}`,
505
545
  "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
546
  ];
507
547
  else if (client === "vscode")
@@ -520,8 +560,13 @@ function nextSteps(client, auth) {
520
560
  ];
521
561
  // `codex mcp add` has no option for it, so a registered server gets the line by hand.
522
562
  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}`];
563
+ 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}`);
564
+ if (project)
565
+ 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.`);
566
+ const verify = project
567
+ ? `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.`
568
+ : MCP_VERIFY_PROMPT;
569
+ return [...lines, "Then ask your agent:", ` ${verify}`];
525
570
  }
526
571
  /**
527
572
  * Runs `hue mcp install` and returns the process exit code: 0 done or printed, 1 failed, 2 usage
@@ -578,6 +623,25 @@ export async function runMcpCommand(argv, io = {}) {
578
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);
579
624
  if (parsed.values["read-only"])
580
625
  url = readOnlyMcpUrl(url);
626
+ // --project replaces a pin already in --url. Without it, validate and keep the URL's pin.
627
+ const urlProjects = new URL(url).searchParams.getAll("project");
628
+ let projectValue = parsed.values.project;
629
+ if (projectValue === undefined && urlProjects.length) {
630
+ const trimmed = urlProjects.map((value) => value.trim());
631
+ if (trimmed.some((value) => !value) || new Set(trimmed).size !== 1)
632
+ return fail("In --url: ?project= must name one project id or slug. Remove empty or conflicting values.", 2);
633
+ projectValue = trimmed[0];
634
+ }
635
+ let project;
636
+ let serverName = SERVER_NAME;
637
+ if (projectValue !== undefined) {
638
+ const selected = parseProject(projectValue);
639
+ if ("error" in selected)
640
+ return fail(`${parsed.values.project === undefined ? "In --url: " : ""}${selected.error}\n\n${MCP_USAGE}`, 2);
641
+ project = selected.project;
642
+ serverName = selected.serverName;
643
+ }
644
+ url = projectMcpUrl(url, project);
581
645
  // --toolsets wins over a selection already in --url, which wins over the client's default.
582
646
  // Both sources are validated: Hue ignores an unknown name and would list its default.
583
647
  const requested = parsed.values.toolsets ?? new URL(url).searchParams.get("toolsets");
@@ -589,12 +653,12 @@ export async function runMcpCommand(argv, io = {}) {
589
653
  toolsets = selected.toolsets;
590
654
  }
591
655
  url = toolsetsMcpUrl(url, toolsets);
592
- const plan = planFor(clientId, auth, scope, url);
656
+ const plan = planFor(clientId, auth, scope, url, serverName);
593
657
  if (parsed.values.print) {
594
658
  stdout.write(plan.snippet);
595
659
  return 0;
596
660
  }
597
- const steps = nextSteps(clientId, auth);
661
+ const steps = nextSteps(clientId, auth, serverName, project);
598
662
  if (plan.kind === "manual") {
599
663
  out(plan.hint);
600
664
  stdout.write(plan.snippet);
@@ -620,7 +684,7 @@ export async function runMcpCommand(argv, io = {}) {
620
684
  out(`Running: ${command.display}`);
621
685
  const code = await runClientCli(executable, command.args, { cwd, env, stdout, stderr });
622
686
  if (code === 0) {
623
- out(`Registered the "${SERVER_NAME}" MCP server (${url}) with ${command.label}.`);
687
+ out(`Registered the "${serverName}" MCP server (${url}) with ${command.label}.`);
624
688
  continue;
625
689
  }
626
690
  failed = true;
@@ -637,7 +701,7 @@ export async function runMcpCommand(argv, io = {}) {
637
701
  let content;
638
702
  try {
639
703
  const existing = await readJsonConfig(path, display);
640
- let merged = mergeServerEntry(existing, plan.key, plan.entry, display);
704
+ let merged = mergeServerEntry(existing, plan.key, plan.entry, display, serverName);
641
705
  if (plan.input)
642
706
  merged = mergeVscodeInput(merged, plan.input, display);
643
707
  content = json(merged);
@@ -658,7 +722,7 @@ export async function runMcpCommand(argv, io = {}) {
658
722
  catch (error) {
659
723
  return fail(`Could not write ${display}: ${error.message}`);
660
724
  }
661
- out(`Wrote ${display} with the "${SERVER_NAME}" MCP server (${url}).`);
725
+ out(`Wrote ${display} with the "${serverName}" MCP server (${url}).`);
662
726
  for (const line of steps)
663
727
  out(line);
664
728
  return 0;
@@ -44,7 +44,8 @@ export declare class EvaluationClient {
44
44
  private readonly timeoutMillis;
45
45
  constructor(options: EvaluationClientOptions);
46
46
  /** Sends one request to Hue, again after the wait while Hue refuses it with a short
47
- * `Retry-After`. `init` runs for every attempt, so each has its own timeout. */
47
+ * `Retry-After`. `init` runs for every attempt, so each has its own timeout; `signal` also ends
48
+ * each attempt, and a wait to send one again, whose refusal is then the caller's error. */
48
49
  private send;
49
50
  private request;
50
51
  /** Verified bytes of one ready artifact in this project, bounded to the 25 MiB pilot file size.
@@ -170,8 +171,10 @@ export declare class EvaluationClient {
170
171
  revokeAttemptConnection(input: {
171
172
  bindingId: string;
172
173
  }): Promise<import("./attempt.js").RevokeAttemptResult>;
173
- /** Reads one artifact reservation and its verification state. */
174
- getArtifact(id: string): Promise<ArtifactReservation>;
174
+ /** Reads one artifact reservation and its verification state. `signal` ends the request. */
175
+ getArtifact(id: string, options?: {
176
+ signal?: AbortSignal;
177
+ }): Promise<ArtifactReservation>;
175
178
  /** Reserves an artifact by declared identity; replaying the key returns the same reservation. */
176
179
  reserveArtifact(input: {
177
180
  /** Stable key; replaying it returns the same reservation. */
@@ -187,8 +190,11 @@ export declare class EvaluationClient {
187
190
  }): Promise<ArtifactReservation>;
188
191
  /** Issues a short-lived storage capability for staging the reserved artifact's bytes. */
189
192
  requestArtifactUpload(id: string): Promise<ArtifactUpload>;
190
- /** Asks Hue to verify the staged bytes against the declared identity. */
191
- completeArtifact(id: string): Promise<ArtifactReservation>;
193
+ /** Asks Hue to verify the staged bytes against the declared identity. `signal` ends the
194
+ * request, and a wait to send it again. */
195
+ completeArtifact(id: string, options?: {
196
+ signal?: AbortSignal;
197
+ }): Promise<ArtifactReservation>;
192
198
  /** Saves an execution's outcome and creates its immutable subject. */
193
199
  completeExecution(id: string, input: CompleteExecution): Promise<Completion>;
194
200
  /** Marks an experiment finished. */
@@ -149,12 +149,15 @@ export class EvaluationClient {
149
149
  this.timeoutMillis = validated.timeoutMillis;
150
150
  }
151
151
  /** Sends one request to Hue, again after the wait while Hue refuses it with a short
152
- * `Retry-After`. `init` runs for every attempt, so each has its own timeout. */
153
- async send(url, init) {
152
+ * `Retry-After`. `init` runs for every attempt, so each has its own timeout; `signal` also ends
153
+ * each attempt, and a wait to send one again, whose refusal is then the caller's error. */
154
+ async send(url, init, signal) {
154
155
  for (let attempt = 0;; attempt++) {
155
156
  let response;
156
157
  try {
157
- response = await fetch(url, init());
158
+ const attemptInit = init();
159
+ const signals = [attemptInit.signal, signal].filter((item) => !!item);
160
+ response = await fetch(url, { ...attemptInit, signal: AbortSignal.any(signals) });
158
161
  }
159
162
  catch {
160
163
  throw new HueApiError();
@@ -164,10 +167,23 @@ export class EvaluationClient {
164
167
  return response;
165
168
  await response.body?.cancel().catch(() => undefined);
166
169
  // Never sooner than asked; the jitter spreads out parallel requests refused together.
167
- await new Promise((resolve) => setTimeout(resolve, seconds * 1000 * (1 + Math.random() * 0.5)));
170
+ await new Promise((resolve) => {
171
+ const done = () => {
172
+ clearTimeout(timer);
173
+ signal?.removeEventListener("abort", done);
174
+ resolve();
175
+ };
176
+ const timer = setTimeout(done, seconds * 1000 * (1 + Math.random() * 0.5));
177
+ signal?.addEventListener("abort", done, { once: true });
178
+ // The signal may have ended while the refusal's body was cancelled.
179
+ if (signal?.aborted)
180
+ done();
181
+ });
182
+ if (signal?.aborted)
183
+ throw new HueApiError(response.status, askedRetryAfter(response));
168
184
  }
169
185
  }
170
- async request(method, path, body, bounds = { ...valueBounds, bytes: 1024 * 1024 }) {
186
+ async request(method, path, body, bounds = { ...valueBounds, bytes: 1024 * 1024 }, signal) {
171
187
  // Optional top-level fields are omitted intentionally; nested undefined remains invalid.
172
188
  const payload = body === undefined
173
189
  ? undefined
@@ -181,7 +197,7 @@ export class EvaluationClient {
181
197
  body: payload,
182
198
  redirect: "error",
183
199
  signal: AbortSignal.timeout(this.timeoutMillis),
184
- }));
200
+ }), signal);
185
201
  if (!response.ok) {
186
202
  await response.body?.cancel();
187
203
  throw new HueApiError(response.status, askedRetryAfter(response));
@@ -503,9 +519,9 @@ export class EvaluationClient {
503
519
  throw new HueApiError();
504
520
  }
505
521
  }
506
- /** Reads one artifact reservation and its verification state. */
507
- getArtifact(id) {
508
- return this.request("GET", `/artifacts/${uuid(id)}`);
522
+ /** Reads one artifact reservation and its verification state. `signal` ends the request. */
523
+ getArtifact(id, options = {}) {
524
+ return this.request("GET", `/artifacts/${uuid(id)}`, undefined, undefined, options.signal);
509
525
  }
510
526
  /** Reserves an artifact by declared identity; replaying the key returns the same reservation. */
511
527
  reserveArtifact(input) {
@@ -515,9 +531,10 @@ export class EvaluationClient {
515
531
  requestArtifactUpload(id) {
516
532
  return this.request("POST", `/artifacts/${uuid(id)}/upload`, {});
517
533
  }
518
- /** Asks Hue to verify the staged bytes against the declared identity. */
519
- completeArtifact(id) {
520
- return this.request("POST", `/artifacts/${uuid(id)}/complete`, {});
534
+ /** Asks Hue to verify the staged bytes against the declared identity. `signal` ends the
535
+ * request, and a wait to send it again. */
536
+ completeArtifact(id, options = {}) {
537
+ return this.request("POST", `/artifacts/${uuid(id)}/complete`, {}, undefined, options.signal);
521
538
  }
522
539
  /** Saves an execution's outcome and creates its immutable subject. */
523
540
  completeExecution(id, input) {
@@ -339,21 +339,33 @@ function mayStillVerify(error) {
339
339
  * a pause: ready is returned; verifying is completed again, which Hue refuses while its lease
340
340
  * holds and restarts once it lapsed; an artifact a retryable refusal (a lost response, 429 or 503)
341
341
  * left unverified is completed again up to three times; any other state is returned for the
342
- * caller to refuse (a resume uploads a rejected artifact again). All within `timing.settleMillis`.
342
+ * caller to refuse (a resume uploads a rejected artifact again). All within `timing.settleMillis`,
343
+ * which also ends a request in flight and the client's own wait to send one again.
343
344
  */
344
345
  async function settleArtifact(client, id, timing) {
345
346
  const deadline = Date.now() + timing.settleMillis;
347
+ const signal = AbortSignal.timeout(timing.settleMillis);
348
+ // The last state read, which names the state when the deadline cuts off a request at it.
349
+ let known;
350
+ const timedOut = (last) => {
351
+ const seconds = Math.round(timing.settleMillis / 1000);
352
+ return new Error(last?.state === "verifying"
353
+ ? `Hue was still verifying generated file ${id} after ${seconds} seconds`
354
+ : `Hue had not verified generated file ${id} after ${seconds} seconds (${last ? `it was ${last.state}` : "its state could not be read"})`);
355
+ };
346
356
  let pause = timing.pollMillis;
347
357
  let retries = 0;
348
358
  for (;;) {
349
359
  let refusedForNow;
350
360
  let asked = 0;
351
361
  try {
352
- return (await client.completeArtifact(id)).state;
362
+ return (await client.completeArtifact(id, { signal })).state;
353
363
  }
354
364
  catch (error) {
355
- if (!mayStillVerify(error) || Date.now() >= deadline)
365
+ if (!mayStillVerify(error))
356
366
  throw error;
367
+ if (Date.now() >= deadline)
368
+ throw timedOut(known);
357
369
  refusedForNow = error.status !== 409;
358
370
  // A refusal that says how long to wait is waited out, within the settling window.
359
371
  asked = (error.retryAfterSeconds ?? 0) * 1000;
@@ -362,7 +374,7 @@ async function settleArtifact(client, id, timing) {
362
374
  pause = Math.min(pause * 2, timing.maxPollMillis);
363
375
  let current;
364
376
  try {
365
- current = await client.getArtifact(id);
377
+ current = known = await client.getArtifact(id, { signal });
366
378
  }
367
379
  catch (error) {
368
380
  if (!mayStillVerify(error))
@@ -375,12 +387,8 @@ async function settleArtifact(client, id, timing) {
375
387
  current.failureCode !== "mismatch";
376
388
  if (current && current.state !== "verifying" && !(unverified && refusedForNow && retries++ < 3))
377
389
  return current.state;
378
- if (Date.now() >= deadline) {
379
- const seconds = Math.round(timing.settleMillis / 1000);
380
- throw new Error(current?.state === "verifying"
381
- ? `Hue was still verifying generated file ${id} after ${seconds} seconds`
382
- : `Hue had not verified generated file ${id} after ${seconds} seconds (${current ? `it was ${current.state}` : "its state could not be read"})`);
383
- }
390
+ if (Date.now() >= deadline)
391
+ throw timedOut(current ?? known);
384
392
  }
385
393
  }
386
394
  /**
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version shared by the instrumentation scope and the export User-Agent. */
2
- export declare const sdkVersion = "0.11.1";
2
+ export declare const sdkVersion = "0.11.2";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/write-version.mjs from package.json; do not edit by hand.
2
2
  /** Package version shared by the instrumentation scope and the export User-Agent. */
3
- export const sdkVersion = "0.11.1";
3
+ export const sdkVersion = "0.11.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {