From ae296071ca3f267fdab94dc603b4f92c3c8205d8 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 22 Sep 2026 22:25:46 -0700 Subject: [PATCH 01/69] feat(cli,core): shared runner surface for the mcpi session client Small, mcpi-motivated additions to shared code, kept separate so the client itself is reviewable on its own: - clients/cli handlers: expose method metadata (method-types) and a reusable run-method entry point for out-of-process callers; unit tests for the mocked run-method paths - clients/cli/src/cli-oauth-navigation.ts: allow callers to supply their own browser-open/navigation hooks - core/auth/node/runner-interactive-oauth.ts: SIGINT/SIGTERM-aware wait so Ctrl-C during an interactive OAuth flow cleans up the callback server (removed in finally); test in clients/web test tree - core/mcp/serverList.ts, core/mcp/types.ts: server-list helpers and types shared by cli and mcpi Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../cli/__tests__/run-method-mocks.test.ts | 35 +++++++++++ clients/cli/src/cli-oauth-navigation.ts | 14 ++++- clients/cli/src/handlers/method-types.ts | 17 ++++- clients/cli/src/handlers/run-method.ts | 32 ++++++++++ .../auth/runner-interactive-oauth.test.ts | 62 +++++++++++++++++++ core/auth/node/runner-interactive-oauth.ts | 15 +++++ core/mcp/serverList.ts | 44 +++++++++++++ core/mcp/types.ts | 32 ++++++++++ 8 files changed, 248 insertions(+), 3 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index 4fda0c5197..222cf09020 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -12,6 +12,7 @@ function mockClient(overrides: Partial = {}): InspectorClient { getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }), cancelRequestorTask: vi.fn().mockResolvedValue(undefined), getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }), + updateRequestorTask: vi.fn().mockResolvedValue(undefined), getRoots: vi.fn().mockReturnValue([]), setRoots: vi.fn().mockResolvedValue(undefined), setLoggingLevel: vi.fn().mockResolvedValue(undefined), @@ -133,6 +134,19 @@ describe("runMethod (mocked client)", () => { }); expect(result.kind).toBe("result"); + const updated = await runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: '{"confirm":{"approved":true}}', + }); + expect(updated.kind).toBe("result"); + if (updated.kind === "result") { + expect(updated.result).toMatchObject({ updated: true, taskId: "t1" }); + } + expect(client.updateRequestorTask).toHaveBeenCalledWith("t1", { + confirm: { approved: true }, + }); + const complete = await runMethod(client, { method: "prompts/complete", completeRefType: "ref/prompt", @@ -191,6 +205,27 @@ describe("runMethod (mocked client)", () => { /tasks\/result/, ); + await expect(runMethod(client, { method: "tasks/update" })).rejects.toThrow( + /tasks\/update/, + ); + await expect( + runMethod(client, { method: "tasks/update", taskId: "t1" }), + ).rejects.toThrow(/--input-responses/); + await expect( + runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: "not-json", + }), + ).rejects.toThrow(/--input-responses is invalid/); + await expect( + runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: "[1,2,3]", + }), + ).rejects.toThrow(/--input-responses is invalid/); + await expect( runMethod(client, { method: "roots/set", diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts index 1f5d1111c0..c0ad05fda0 100644 --- a/clients/cli/src/cli-oauth-navigation.ts +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -50,6 +50,15 @@ export type CliOAuthNavigationOptions = { * (`MCP_AUTO_OPEN_ENABLED=true`). */ forceAutoOpen?: boolean; + /** + * Build the printed prompt line for a given authorize URL. Receives the + * (possibly OSC-8-linked) display string and whether stderr is a TTY. + * Defaults to the CLI's own "Please navigate to: " framing. Override + * when a different caller needs different wording — e.g. mcpi, addressed to + * whatever is running it (which may be an agent that must relay the link to + * a human) rather than to a human reading the terminal directly. + */ + promptMessage?: (hrefDisplay: string, tty: boolean) => string; }; /** @@ -108,7 +117,10 @@ export function createCliOAuthNavigation( ); const write = options.write ?? ((line: string) => process.stderr.write(line)); - write(`Please navigate to: ${style.link(href)}\n`); + const promptMessage = + options.promptMessage ?? + ((hrefDisplay: string) => `Please navigate to: ${hrefDisplay}`); + write(`${promptMessage(style.link(href), tty)}\n`); const envAllows = options.autoOpenEnabled !== undefined diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 958552dcea..9c5260b94c 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -30,7 +30,7 @@ export type MethodArgs = { */ strict?: boolean; format?: OutputFormat; - /** Task id for tasks/get, tasks/cancel, tasks/result. */ + /** Task id for tasks/get, tasks/cancel, tasks/result, tasks/update. */ taskId?: string; /** When true, tools/call uses callToolStream (task-augmented). */ task?: boolean; @@ -48,6 +48,12 @@ export type MethodArgs = { cursor?: string; /** roots/set payload (JSON array of {uri, name?}). */ rootsJson?: string; + /** + * tasks/update payload (JSON object keyed by the server's `inputRequests` + * ids). Resumes a modern (SEP-2663) task paused on `input_required` — + * modern-only, symmetric with `roots/set`'s JSON-blob convention. + */ + inputResponsesJson?: string; /** prompts/complete: argument name / value / ref. */ completeRefType?: "ref/prompt" | "ref/resource"; completeRef?: string; @@ -91,9 +97,15 @@ export type MethodOutcome = * TODO(#1432): several of these (subscribe, tasks, roots, logging/tail, …) are * not exposed by `mcp-inspector --cli` today; they exist for the experimental * session CLI (`mcpi`) and other Node runners that share this dispatcher. + * + * Deliberately excludes `"initialize"` — that's still a valid {@link + * ONE_SHOT_METHODS} entry (scripting parity with the literal wire method + * name), but for `mcpi` it read as "send another initialize", which it never + * did (it only replays cached connect-time state). `mcpi sessions/show` + * covers the same data (server info, capabilities, negotiated era) alongside + * daemon session bookkeeping instead. */ export const SESSION_RPC_METHODS = [ - "initialize", "tools/list", "tools/call", "resources/list", @@ -111,6 +123,7 @@ export const SESSION_RPC_METHODS = [ "tasks/get", "tasks/cancel", "tasks/result", + "tasks/update", "roots/list", "roots/set", "skills/list", diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index f3d883e00e..1d30e5be51 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -314,6 +314,38 @@ export async function runMethod( result = (await inspectorClient.getRequestorTaskResult( args.taskId, )) as McpResponse; + } else if (args.method === "tasks/update") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/update. Use --task-id."); + } + if (!args.inputResponsesJson) { + throw new Error( + "tasks/update requires --input-responses ''.", + ); + } + let inputResponses: Record; + try { + const parsed: unknown = JSON.parse(args.inputResponsesJson); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("must be a JSON object"); + } + inputResponses = parsed as Record; + } catch (e) { + throw new Error( + `--input-responses is invalid: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + await inspectorClient.updateRequestorTask(args.taskId, inputResponses); + // The server acks with an empty result and the task's status advances + // only on a subsequent tasks/get poll (updateRequestorTask says so) — + // so echo back what was actually sent rather than imply a fresher + // status is available here. + result = { updated: true, taskId: args.taskId }; } else if (args.method === "skills/list") { // The store's cursor walk is reused rather than re-implemented — it // carries the repeated-cursor and page-cap guards, and a second copy of diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index b141c1cba8..1c53e4dc4f 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -522,4 +522,66 @@ describe("runRunnerInteractiveOAuth", () => { ).rejects.toThrow("bind failed"); expect(mockServer.stop).toHaveBeenCalled(); }); + + it("rejects cleanly on SIGINT while waiting on the callback, instead of hanging or killing the process", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + // Give beginInteractiveAuthorization/authenticate a tick to register the + // listener before the signal fires. + await Promise.resolve(); + await Promise.resolve(); + process.emit("SIGINT", "SIGINT"); + + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGINT).", + ); + expect(mockServer.stop).toHaveBeenCalled(); + // The handler must be removed once the wait settles, so a later SIGINT + // elsewhere in the process isn't accidentally swallowed by a stale + // listener from this call. + expect(process.listenerCount("SIGINT")).toBe(0); + }); + + it("rejects cleanly on SIGTERM the same way", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + await Promise.resolve(); + await Promise.resolve(); + process.emit("SIGTERM", "SIGTERM"); + + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGTERM).", + ); + expect(process.listenerCount("SIGTERM")).toBe(0); + }); }); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index ce0dd199ee..4adfbbbdb0 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -78,6 +78,19 @@ export async function runRunnerInteractiveOAuth( flowReject = reject; }); + // Ctrl-C / a caller killing the process while waiting on the loopback + // callback would otherwise either hang until the timeout below or (for + // SIGINT specifically, absent any handler) hit Node's default abrupt exit + // with no cleanup. Reject cleanly instead so the server is stopped and the + // caller gets a normal, classifiable error ("OAuth" in the message maps to + // AUTH_REQUIRED — see clients/cli/src/error-handler.ts) rather than a raw + // process death. + const onSignal = (signal: NodeJS.Signals) => { + flowReject(new Error(`OAuth authorization cancelled (${signal}).`)); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + let timeoutId: ReturnType | undefined; try { @@ -154,6 +167,8 @@ export async function runRunnerInteractiveOAuth( return { kind: "success" }; } finally { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); if (timeoutId !== undefined) { clearTimeout(timeoutId); } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 9814008ed5..1849c2ff4e 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -7,6 +7,7 @@ import { DEFAULT_CONNECTION_TIMEOUT_MS, + DEFAULT_ELICIT_CAPABILITY, DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_MODERN_LOG_LEVEL, DEFAULT_PROTOCOL_ERA, @@ -20,6 +21,7 @@ import { } from "./skills.js"; import type { Root } from "@modelcontextprotocol/client"; import type { + ElicitCapabilityMode, InspectorServerSettings, RequestMetadata, MCPConfig, @@ -52,6 +54,28 @@ const VALID_PROTOCOL_ERAS: ReadonlySet = new Set([ "modern", ]); +const VALID_ELICIT_CAPABILITIES: ReadonlySet = new Set([ + "off", + "url", + "form", + "both", +]); + +/** + * Runtime guard for the `elicitCapability` literal, mirroring + * {@link isProtocolEra}: a hand-edited `mcp.json` read directly by the CLI/TUI + * can carry any string, and an unknown value should read back as the default + * rather than propagate to `createSessionClient`. + */ +export function isElicitCapability( + value: unknown, +): value is ElicitCapabilityMode { + return ( + typeof value === "string" && + VALID_ELICIT_CAPABILITIES.has(value as ElicitCapabilityMode) + ); +} + /** * Runtime guard for the `protocolEra` literal. `StoredMCPServer` types the * field as `ServerProtocolEra`, but a hand-edited `mcp.json` read directly by @@ -157,6 +181,7 @@ type StoredInspectorFields = Pick< | "headers" | "metadata" | "protocolEra" + | "elicitCapability" | "modernLogLevel" | "connectionTimeout" | "requestTimeout" @@ -535,6 +560,7 @@ export function storedFieldsToInspectorSettings( stored.oauth !== undefined || stored.roots !== undefined || stored.protocolEra !== undefined || + stored.elicitCapability !== undefined || stored.modernLogLevel !== undefined || stored.env !== undefined || stored.cwd !== undefined; @@ -590,6 +616,12 @@ export function storedFieldsToInspectorSettings( if (isProtocolEra(stored.protocolEra)) { settings.protocolEra = stored.protocolEra; } + // Like `protocolEra`: absent reads back as the default elicitation + // capability (`"both"`), and an unknown literal from a hand-edited file is + // dropped rather than surfaced. + if (isElicitCapability(stored.elicitCapability)) { + settings.elicitCapability = stored.elicitCapability; + } // Like `protocolEra`: absent reads back as the default modern log level (the // form defaults via `?? DEFAULT_MODERN_LOG_LEVEL`), and an unknown literal from // a hand-edited file is dropped rather than surfaced. @@ -749,6 +781,17 @@ export function inspectorSettingsToStoredFields( out.protocolEra = settings.protocolEra; } + // Persist only when it differs from the default elicitation capability; + // absent reads back as DEFAULT_ELICIT_CAPABILITY, so writing the default + // would inject the field into hand-edited files that never had it and break + // byte-stable round-trips. + if ( + settings.elicitCapability !== undefined && + settings.elicitCapability !== DEFAULT_ELICIT_CAPABILITY + ) { + out.elicitCapability = settings.elicitCapability; + } + // Persist only when it differs from the default modern log level; absent reads // back as DEFAULT_MODERN_LOG_LEVEL, so writing the default would inject the // field into files that never set it and break byte-stable round-trips. @@ -854,6 +897,7 @@ const INSPECTOR_FIELD_KEY_MAP = { headers: true, metadata: true, protocolEra: true, + elicitCapability: true, modernLogLevel: true, connectionTimeout: true, requestTimeout: true, diff --git a/core/mcp/types.ts b/core/mcp/types.ts index d954b8a212..8906a1bcc3 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -125,6 +125,13 @@ export type StoredMCPServer = MCPServerConfig & { * (`"legacy"`). (#1626) */ protocolEra?: ServerProtocolEra; + /** + * Elicitation capability this client advertises to this server + * (`"off" | "url" | "form" | "both"`). Inspector-specific (no analog in the + * broader mcp.json ecosystem). Omitted on disk when it equals the default + * (`"both"`). Currently consumed by mcpi only. (#1783) + */ + elicitCapability?: ElicitCapabilityMode; /** * Modern-era per-request log level stamped by default (`"off"` or one of the * eight logging levels). Inspector-specific. Omitted on disk when it equals @@ -730,6 +737,15 @@ export type ServerProtocolEra = "legacy" | "auto" | "modern"; /** The default per-server protocol era when none is configured. */ export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy"; +/** + * Elicitation capability mode a client advertises to a server for one + * connection — see {@link InspectorServerSettings.elicitCapability}. + */ +export type ElicitCapabilityMode = "off" | "url" | "form" | "both"; + +/** The default elicitation capability mode when none is configured. */ +export const DEFAULT_ELICIT_CAPABILITY: ElicitCapabilityMode = "both"; + /** * Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel` * is gone on the modern era; instead the client opts into logs by stamping @@ -988,6 +1004,22 @@ export interface InspectorServerSettings { * omitted when it equals the default, keeping the file diff minimal. */ protocolEra?: ServerProtocolEra; + /** + * Elicitation capability this client advertises to the server for this + * connection: `"off"` (no `capabilities.elicitation` at all — the server + * sees a client that can't do elicitation and can fall back to whatever + * it does when the capability is absent, e.g. proceeding with defaults or + * failing its own way, rather than getting a guaranteed decline/cancel), + * `"url"` (URL-mode only), `"form"` (form-mode only), or `"both"`. Optional + * so a bare settings node reads back without one; absence means {@link + * DEFAULT_ELICIT_CAPABILITY} (`"both"`). Persisted on disk as + * `elicitCapability` and omitted when it equals the default. Currently + * consumed by mcpi only (#1783) — a connect-time, sticky-per-session + * choice rather than a per-call one, since a daemon-managed session can be + * reused by several later callers (interactive and scripted) over its + * lifetime. + */ + elicitCapability?: ElicitCapabilityMode; /** * Modern-era per-request log level stamped by default on this server's * connections (#1629). One of the eight logging levels, or `"off"` to not opt From 0da027884db0843a89a71dfba41e6809ff90f07f Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 22 Sep 2026 22:25:58 -0700 Subject: [PATCH 02/69] feat(mcpi): experimental session CLI client (#1432) Add clients/mcpi, an experimental session-oriented CLI: connect once, then run many MCP commands against a named session held open by an implicit local Unix-socket daemon (ssh-agent style). Not part of the published package; runs from a repo checkout (npm link). Highlights: - Session daemon (auto-spawned, idle self-reaping) with NDJSON IPC, token-gated private mode (`mcpi private`), MRU session selection - Full command surface via shared clients/cli handlers: tools, resources, prompts, skills, tasks, completions, logging, sampling, elicitation (interactive form prompts and agent-answerable modes) - OAuth support including stored-token reuse, interactive browser flows, and enterprise-managed auth (EMA): --ema connect flag, auth/ema-status|login|logout, per-session Auth reporting with disk-truth reads in sessions/show - Era detection/reporting (legacy vs 2025-11-25) per session - Human and JSON output formats; agent-focused skills/mcpi/SKILL.md - Spec: specification/v2_cli_v2.md; docs in clients/mcpi/README.md - Tests: 221 unit/integration tests, per-file coverage gates wired into the repo quality gate (coverage:mcpi, validate:mcpi) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- AGENTS.md | 12 +- README.md | 4 +- clients/mcpi/README.md | 171 + clients/mcpi/__tests__/agent-help.test.ts | 20 + clients/mcpi/__tests__/authorize.test.ts | 119 + .../mcpi/__tests__/daemon-coverage.test.ts | 811 ++++ clients/mcpi/__tests__/daemon-paths.test.ts | 99 + clients/mcpi/__tests__/daemon-private.test.ts | 229 ++ .../mcpi/__tests__/daemon-sessions.test.ts | 537 +++ clients/mcpi/__tests__/daemon-stream.test.ts | 312 ++ clients/mcpi/__tests__/dispatch.test.ts | 249 ++ .../mcpi/__tests__/elicitation-bridge.test.ts | 184 + .../mcpi/__tests__/elicitation-client.test.ts | 305 ++ .../mcpi/__tests__/elicitation-prompt.test.ts | 260 ++ clients/mcpi/__tests__/ema-commands.test.ts | 152 + clients/mcpi/__tests__/ema.test.ts | 278 ++ clients/mcpi/__tests__/form-prompt.test.ts | 399 ++ clients/mcpi/__tests__/form-schema.test.ts | 284 ++ clients/mcpi/__tests__/format-session.test.ts | 852 +++++ clients/mcpi/__tests__/helpers/mcp-runner.ts | 88 + clients/mcpi/__tests__/hoist-session.test.ts | 22 + .../mcpi/__tests__/mcp-auth-coverage.test.ts | 285 ++ clients/mcpi/__tests__/mcp-coverage.test.ts | 468 +++ clients/mcpi/__tests__/mcp-session.test.ts | 218 ++ .../mcpi/__tests__/parse-tool-args.test.ts | 119 + .../__tests__/session-stored-auth.test.ts | 249 ++ clients/mcpi/eslint.config.js | 17 + clients/mcpi/package-lock.json | 3387 +++++++++++++++++ clients/mcpi/package.json | 51 + clients/mcpi/src/daemon/auth.ts | 43 + clients/mcpi/src/daemon/client.ts | 216 ++ clients/mcpi/src/daemon/elicitation-bridge.ts | 100 + clients/mcpi/src/daemon/ensure.ts | 147 + clients/mcpi/src/daemon/framing.ts | 28 + clients/mcpi/src/daemon/index.ts | 36 + clients/mcpi/src/daemon/ipc-glue.ts | 203 + clients/mcpi/src/daemon/paths.ts | 67 + clients/mcpi/src/daemon/protocol.ts | 221 ++ clients/mcpi/src/daemon/run.ts | 29 + clients/mcpi/src/daemon/server.ts | 426 +++ clients/mcpi/src/daemon/sessions.ts | 517 +++ clients/mcpi/src/daemon/stream-client.ts | 183 + clients/mcpi/src/mcp-bin.ts | 28 + clients/mcpi/src/session/authorize.ts | 137 + clients/mcpi/src/session/dispatch.ts | 159 + .../mcpi/src/session/elicitation-prompt.ts | 170 + clients/mcpi/src/session/ema.ts | 235 ++ clients/mcpi/src/session/form-prompt.ts | 251 ++ clients/mcpi/src/session/form-schema.ts | 176 + clients/mcpi/src/session/format-human.ts | 834 ++++ clients/mcpi/src/session/format-session.ts | 302 ++ clients/mcpi/src/session/mcp.ts | 1124 ++++++ clients/mcpi/src/session/parse-tool-args.ts | 134 + clients/mcpi/src/session/private-env.ts | 37 + clients/mcpi/src/session/stored-auth.ts | 151 + clients/mcpi/tsconfig.json | 23 + clients/mcpi/tsconfig.test.json | 29 + clients/mcpi/tsup.config.ts | 42 + clients/mcpi/vitest.config.ts | 50 + package.json | 14 +- scripts/install-clients.mjs | 2 +- scripts/lib/workflow-gate.test.mjs | 4 +- scripts/verify-bundle-externals.mjs | 19 +- scripts/verify-format-coverage.mjs | 1 + scripts/verify-test-timeouts.mjs | 2 + scripts/verify-test-timeouts.test.mjs | 3 +- skills/mcpi/SKILL.md | 52 + specification/v2_catalog_launch_config.md | 2 +- specification/v2_cli_tui_launcher.md | 7 +- specification/v2_cli_v2.md | 185 + 70 files changed, 16548 insertions(+), 22 deletions(-) create mode 100644 clients/mcpi/README.md create mode 100644 clients/mcpi/__tests__/agent-help.test.ts create mode 100644 clients/mcpi/__tests__/authorize.test.ts create mode 100644 clients/mcpi/__tests__/daemon-coverage.test.ts create mode 100644 clients/mcpi/__tests__/daemon-paths.test.ts create mode 100644 clients/mcpi/__tests__/daemon-private.test.ts create mode 100644 clients/mcpi/__tests__/daemon-sessions.test.ts create mode 100644 clients/mcpi/__tests__/daemon-stream.test.ts create mode 100644 clients/mcpi/__tests__/dispatch.test.ts create mode 100644 clients/mcpi/__tests__/elicitation-bridge.test.ts create mode 100644 clients/mcpi/__tests__/elicitation-client.test.ts create mode 100644 clients/mcpi/__tests__/elicitation-prompt.test.ts create mode 100644 clients/mcpi/__tests__/ema-commands.test.ts create mode 100644 clients/mcpi/__tests__/ema.test.ts create mode 100644 clients/mcpi/__tests__/form-prompt.test.ts create mode 100644 clients/mcpi/__tests__/form-schema.test.ts create mode 100644 clients/mcpi/__tests__/format-session.test.ts create mode 100644 clients/mcpi/__tests__/helpers/mcp-runner.ts create mode 100644 clients/mcpi/__tests__/hoist-session.test.ts create mode 100644 clients/mcpi/__tests__/mcp-auth-coverage.test.ts create mode 100644 clients/mcpi/__tests__/mcp-coverage.test.ts create mode 100644 clients/mcpi/__tests__/mcp-session.test.ts create mode 100644 clients/mcpi/__tests__/parse-tool-args.test.ts create mode 100644 clients/mcpi/__tests__/session-stored-auth.test.ts create mode 100644 clients/mcpi/eslint.config.js create mode 100644 clients/mcpi/package-lock.json create mode 100644 clients/mcpi/package.json create mode 100644 clients/mcpi/src/daemon/auth.ts create mode 100644 clients/mcpi/src/daemon/client.ts create mode 100644 clients/mcpi/src/daemon/elicitation-bridge.ts create mode 100644 clients/mcpi/src/daemon/ensure.ts create mode 100644 clients/mcpi/src/daemon/framing.ts create mode 100644 clients/mcpi/src/daemon/index.ts create mode 100644 clients/mcpi/src/daemon/ipc-glue.ts create mode 100644 clients/mcpi/src/daemon/paths.ts create mode 100644 clients/mcpi/src/daemon/protocol.ts create mode 100644 clients/mcpi/src/daemon/run.ts create mode 100644 clients/mcpi/src/daemon/server.ts create mode 100644 clients/mcpi/src/daemon/sessions.ts create mode 100644 clients/mcpi/src/daemon/stream-client.ts create mode 100644 clients/mcpi/src/mcp-bin.ts create mode 100644 clients/mcpi/src/session/authorize.ts create mode 100644 clients/mcpi/src/session/dispatch.ts create mode 100644 clients/mcpi/src/session/elicitation-prompt.ts create mode 100644 clients/mcpi/src/session/ema.ts create mode 100644 clients/mcpi/src/session/form-prompt.ts create mode 100644 clients/mcpi/src/session/form-schema.ts create mode 100644 clients/mcpi/src/session/format-human.ts create mode 100644 clients/mcpi/src/session/format-session.ts create mode 100644 clients/mcpi/src/session/mcp.ts create mode 100644 clients/mcpi/src/session/parse-tool-args.ts create mode 100644 clients/mcpi/src/session/private-env.ts create mode 100644 clients/mcpi/src/session/stored-auth.ts create mode 100644 clients/mcpi/tsconfig.json create mode 100644 clients/mcpi/tsconfig.test.json create mode 100644 clients/mcpi/tsup.config.ts create mode 100644 clients/mcpi/vitest.config.ts create mode 100644 skills/mcpi/SKILL.md create mode 100644 specification/v2_cli_v2.md diff --git a/AGENTS.md b/AGENTS.md index 1c0b7d9068..f04cd2b1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # Inspector V2 -This is an application for inspecting MCP servers. It has three incarnations — -Web, TUI, and CLI — over a shared `core/`. +This is an application for inspecting MCP servers. It has four client +surfaces — Web, TUI, one-shot CLI, and the experimental session CLI (`mcpi`) — +over a shared `core/`. **This file holds the _rules_: the conventions a reviewer cites against a diff.** It is loaded in full on every turn, so it stays resident and must stay complete @@ -41,6 +42,9 @@ inspector/ │ │ ├── server/ Node-only dev/prod backend wiring │ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab │ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias) +│ ├── mcpi/ Experimental session CLI (`mcpi` bin — connect once, many +│ │ commands; implicit Unix-socket session daemon). Not part +│ │ of the published package yet — see clients/mcpi/README.md │ ├── tui/ Ink + React terminal UI (tsup bundle) │ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process ├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json) @@ -393,12 +397,12 @@ When asked to respond to a code review of a PR: The _procedure_ — where a given test file goes, which command runs it, how to diagnose a failing gate — is the `testing` skill. These are the rules. -- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui` and `clients/launcher`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. +- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes bootstraps + hard-to-stabilize accept/stream races from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API). - **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise. - **In unit tests that expect error output, suppress it from the console.** - **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.** - **`clients/web`**: `.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — _placement is the manifest_, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`). - - **`clients/cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`. + - **`clients/cli`, `clients/mcpi`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`. - **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0. - **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling *inside* the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance. - **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. diff --git a/README.md b/README.md index 06ae527f92..ec2a3df89d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ inspector/ ├── clients/ │ ├── web/ Web client (Vite + React + Mantine). src/ = browser app; server/ = Node backend │ ├── cli/ CLI client (tsup bundle, @inspector/core alias) +│ ├── mcpi/ Experimental session CLI (`mcpi` bin) — not part of the +│ │ published package; see clients/mcpi/README.md │ ├── tui/ TUI client (Ink + React, tsup bundle) │ └── launcher/ Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ Shared code consumed via the `@inspector/core` alias (no package.json) @@ -66,7 +68,7 @@ inspector/ ``` Each client has its own README with client-specific detail: -[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). +[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [mcpi](./clients/mcpi/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). ## Documentation diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md new file mode 100644 index 0000000000..c39e405d20 --- /dev/null +++ b/clients/mcpi/README.md @@ -0,0 +1,171 @@ +# MCP Inspector session CLI (`mcpi`) + +**Experimental** separate client — not part of the published `@modelcontextprotocol/inspector` package. Connect once, then run many MCP commands against a named session via an implicit local daemon (ssh-agent style). + +> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists. + +## Install / run (from this repo) + +Build, then put `mcpi` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): + +```bash +# from the repo root — install deps once if needed +npm install + +cd clients/mcpi +npm run build +npm link + +mcpi --help +``` + +Rebuild after pulling source changes (`npm run build` in `clients/mcpi`). You usually do **not** need to re-link unless the package `bin` entry changes. + +### Development loop + +`mcpi` itself is a short-lived process re-executed on every invocation, so a +plain rebuild is enough for its changes to take effect on the next command. +The **session daemon** (`build/daemon.js`) is different: `ensureDaemon` (see +`src/daemon/ensure.ts`) reuses an already-running daemon without checking its +code version, so a daemon started before your rebuild keeps running stale +code indefinitely. + +Use `npm run build:dev` instead of `npm run build` while iterating: it runs +`mcpi daemon stop` first (harmless/no-op if no daemon is running — it treats +"daemon not running" as success) and then `tsup`, so the next daemon-backed +command (`connect`, `tools/list`, …) spawns a fresh daemon from the code you +just built. Commands that never touch the daemon (`servers/list`, +`servers/show`, `--help`) don't need this — a plain `npm run build` is enough +for those. + +Without linking, run the built file directly: + +```bash +node clients/mcpi/build/mcp-bin.js --help +``` + +Remove the link when you’re done: + +```bash +npm unlink -g @modelcontextprotocol/mcpi +``` + +## Usage + +```bash +mcpi servers/list --config path/to/mcp.json +mcpi servers/show test-stdio --config path/to/mcp.json +mcpi connect test-stdio --config path/to/mcp.json +mcpi connect my-http --config path/to/mcp.json --relogin # ignore stored OAuth; login only if auth required +mcpi auth/list +mcpi auth/clear https://example.com/mcp +mcpi auth/clear --all --yes +mcpi tools/list +mcpi tools/call echo message:=hi +mcpi tools/call echo '{"message":"hi"}' +mcpi @test-stdio resources/list +mcpi logging/tail # long-lived; Ctrl-C to stop +mcpi sessions/list +mcpi disconnect --session test-stdio +mcpi daemon status +mcpi daemon stop + +# Optional: private daemon for this shell only +eval "$(mcpi private)" +mcpi connect test-stdio --config path/to/mcp.json +mcpi tools/list +``` + +**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. + +**Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope. + +**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-session step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). + +See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. + +## Protocol era support + +mcpi shares `core`'s `InspectorClient`, so it negotiates whichever era +(`legacy` 2025-03-26-style vs. `modern`/2026-era, e.g. task-augmented calls, +`server/discover`) the target actually speaks — no extra flags needed for +that to work. Two things are mcpi-specific: + +- **`--era ` on `connect`**: `legacy` (default), `auto` (probe via + `server/discover` before connecting), or `modern`. Overrides whatever a + catalog/config entry's `protocolEra` says, and is the only way to set it + for an ad-hoc target (no config entry to read one from). + + ```bash + mcpi connect my-modern-server --config path/to/mcp.json --era modern + mcpi connect https://example.com/mcp --era auto + ``` + +- **Era visibility in session output**: `sessions/list`, `sessions/use`, and + `connect` all show the negotiated era inline (`@name (MRU) — server +[modern]`). `sessions/show ` gives the full picture — era, negotiated + protocol version, server info, capabilities, and (when the connect probed + `server/discover`) the server's supported-versions list: + + ``` + $ mcpi sessions/show my-modern-server + Session: my-modern-server + Server: https://example.com/mcp + Era: modern (2026-06-18) + Supported versions: 2025-03-26, 2026-06-18 + ... + ``` + +A paused modern (SEP-2663) task — one whose `tasks/get` shows +`status: "input_required"` — can be resumed with `tasks/update`: + +```bash +mcpi tasks/update --input-responses '{"":{"approved":true}}' +``` + +## Elicitation support + +mcpi can prompt interactively for both elicitation delivery mechanisms — +legacy server→client `elicitation/create` requests and modern non-task MRTR +(multi-round tool response) rounds — and both modes a server may ask for: + +- **URL mode**: mcpi prints the URL and waits for you to confirm you've + finished out-of-band (there's no "decline", only accept-that-you-finished + or cancel — the actual completion can't be observed locally). +- **Form mode**: mcpi renders one prompt per field from the schema, with a + review step (edit any field again, or submit) before answering. + +Non-interactive callers (`--format json`, no TTY, or a script) get an +automatic decline instead of hanging on a prompt. + +By default mcpi advertises **both** modes to the server (`elicit: {url, +form}`), matching pre-#1783 behavior. Override this per connection with +`--elicit ` on `connect`: + +- `off` — advertise no elicitation capability at all. Useful when whatever is + driving mcpi (a script, an agent) can't handle an interactive prompt itself + — omitting the capability lets a well-behaved server fall back to its own + alternative (e.g. proceeding with defaults) instead of the request being + auto-declined. +- `url` — URL mode only. +- `form` — form mode only. +- `both` — the default; both modes. + +Like `--era`, this overrides whatever a catalog/config entry's +`elicitCapability` says, and is the only way to set it for an ad-hoc target +(no config entry to read one from): + +```bash +mcpi connect my-server --config path/to/mcp.json --elicit off +mcpi connect https://example.com/mcp --elicit url +``` + +## Relation to one-shot CLI + +| | One-shot | Session (`mcpi`) | +| ------------- | ------------------------------------- | ------------------------------- | +| Entrypoint | `mcp-inspector --cli` | `mcpi` | +| Package (dev) | `clients/cli` | `clients/mcpi` | +| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands | + +One-shot docs: [`clients/cli/README.md`](../cli/README.md). diff --git a/clients/mcpi/__tests__/agent-help.test.ts b/clients/mcpi/__tests__/agent-help.test.ts new file mode 100644 index 0000000000..89f5fb5978 --- /dev/null +++ b/clients/mcpi/__tests__/agent-help.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { existsSync } from "node:fs"; +import { runMcp } from "./helpers/mcp-runner.js"; + +describe("mcpi agent-help", () => { + it("prints skills/mcpi/SKILL.md content, including its frontmatter", async () => { + const result = await runMcp(["agent-help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("name: mcpi"); + expect(result.stdout).toContain("mcpi connect"); + }); + + it("--path prints the resolved SKILL.md file path", async () => { + const result = await runMcp(["agent-help", "--path"]); + expect(result.exitCode).toBe(0); + const printedPath = result.stdout.trim(); + expect(printedPath.endsWith("skills/mcpi/SKILL.md")).toBe(true); + expect(existsSync(printedPath)).toBe(true); + }); +}); diff --git a/clients/mcpi/__tests__/authorize.test.ts b/clients/mcpi/__tests__/authorize.test.ts new file mode 100644 index 0000000000..7c7c54d07e --- /dev/null +++ b/clients/mcpi/__tests__/authorize.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const connectSpy = vi.fn(); +const disconnectSpy = vi.fn().mockResolvedValue(undefined); +const navigationSpy = vi.fn(); + +vi.mock("@inspector/cli/cliOAuth.js", () => ({ + connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), +})); + +vi.mock("@inspector/cli/cli-oauth-navigation.js", () => ({ + createCliOAuthNavigation: (...args: unknown[]) => { + navigationSpy(...args); + return { navigate: vi.fn() }; + }, +})); + +vi.mock("@inspector/core/mcp/index.js", () => ({ + InspectorClient: class { + connect = vi.fn(); + disconnect = disconnectSpy; + }, +})); + +vi.mock("@inspector/core/client/runner.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadRunnerClientConfig: vi.fn().mockResolvedValue({}), + buildRunnerClientAuthOptions: vi.fn().mockReturnValue({}), + }; +}); + +describe("authorizeInFrontend", () => { + afterEach(() => { + connectSpy.mockReset(); + disconnectSpy.mockClear(); + navigationSpy.mockClear(); + }); + + it("no-ops for non-OAuth-capable (stdio) configs", async () => { + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "stdio", command: "x" } as MCPServerConfig, + undefined, + ); + expect(connectSpy).not.toHaveBeenCalled(); + }); + + it("runs connectInspectorWithOAuth for HTTP configs", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + { protocolEra: "2025-11-25" } as never, + { storedAuthOnly: true }, + ); + expect(connectSpy).toHaveBeenCalled(); + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("swallows disconnect failures in finally", async () => { + connectSpy.mockResolvedValue(undefined); + disconnectSpy.mockRejectedValueOnce(new Error("bye")); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await expect( + authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ), + ).resolves.toBeUndefined(); + }); + + it("always admits interactive OAuth (isTTY: true), regardless of the real TTY state", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = connectSpy.mock.calls[0]?.[5] as { isTTY?: boolean }; + expect(options.isTTY).toBe(true); + }); + + it("addresses the printed authorization line to whoever must relay it — a human directly, or an agent on behalf of one", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const navOptions = navigationSpy.mock.calls[0]?.[0] as { + promptMessage: (hrefDisplay: string, tty: boolean) => string; + }; + expect(navOptions.promptMessage("https://example.com/auth", true)).toBe( + "Please navigate to: https://example.com/auth", + ); + expect(navOptions.promptMessage("https://example.com/auth", false)).toBe( + "The user needs to navigate to this link to authenticate: https://example.com/auth", + ); + }); + + it("maps EmaClientNotConfiguredError to actionable mcpi guidance", async () => { + const { EmaClientNotConfiguredError } = + await import("@inspector/core/auth/ema/clientConfigError.js"); + connectSpy.mockRejectedValue(new EmaClientNotConfiguredError("disabled")); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await expect( + authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ), + ).rejects.toThrow(/EMA.*disabled/i); + // Still tears the probe client down on the error path. + expect(disconnectSpy).toHaveBeenCalled(); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts new file mode 100644 index 0000000000..ee4a1faebb --- /dev/null +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -0,0 +1,811 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { DaemonServer } from "../src/daemon/server.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { ensureDaemon, resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { SessionRegistry } from "../src/daemon/sessions.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; + +describe("daemon coverage", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + let configPath: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function freshDir(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-")); + return dir; + } + + it("handle() covers invalid connect / sessions/use / unknown op", async () => { + server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + const badConnect = await server.handle({ + id: "1", + op: "connect", + params: { name: "" } as never, + }); + expect(badConnect.ok).toBe(false); + if (!badConnect.ok) expect(badConnect.error.code).toBe("invalid_params"); + + const badUse = await server.handle({ + id: "2", + op: "sessions/use", + params: {}, + }); + expect(badUse.ok).toBe(false); + + // sessions/show with no `params` at all exercises the `request.params ?? + // {}` fallback; with no active session it still fails, same shape as + // sessions/use above. + const badShow = await server.handle({ id: "2b", op: "sessions/show" }); + expect(badShow.ok).toBe(false); + + const unknown = await server.handle({ + id: "3", + op: "nope" as never, + }); + expect(unknown.ok).toBe(false); + if (!unknown.ok) expect(unknown.error.code).toBe("unknown_op"); + + // CliExitCodeError without an envelope → default code "cli_error". + const bare = new CliExitCodeError(1, "bare"); + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw bare; + }); + const listed = await server.handle({ id: "4", op: "sessions/list" }); + expect(listed.ok).toBe(false); + if (!listed.ok) expect(listed.error.code).toBe("cli_error"); + + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw new Error("boom"); + }); + const boom = await server.handle({ id: "5", op: "sessions/list" }); + expect(boom.ok).toBe(false); + // Non-CliExitCodeError failures go through classifyError (code "error"). + if (!boom.ok) expect(boom.error.code).toBe("error"); + + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw "string-throw"; + }); + const strErr = await server.handle({ id: "6", op: "sessions/list" }); + expect(strErr.ok).toBe(false); + + const disc = await server.handle({ + id: "7", + op: "disconnect", + params: undefined, + }); + expect(disc.ok).toBe(false); + + // Defaults constructor + stop without onShutdown + re-entrant stop. + const plain = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + await plain.start(); + await plain.stop("stop"); + await plain.stop("stop"); + + // Constructor default dir/idle/onShutdown branches (isolated storage dir). + const prev = process.env.MCP_INSPECTOR_DAEMON_DIR; + process.env.MCP_INSPECTOR_DAEMON_DIR = freshDir(); + try { + const defs = new DaemonServer(); + expect(defs.socketPath).toContain("daemon.sock"); + } finally { + if (prev === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR; + else process.env.MCP_INSPECTOR_DAEMON_DIR = prev; + } + }); + + it("rejects a second listen when a live daemon owns the socket", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const other = new DaemonServer({ dir: d, idleMs: 0 }); + await expect(other.start()).rejects.toThrow(/already running/); + }); + + it("removes a stale socket before binding", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + fs.writeFileSync(sock, ""); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + expect(fs.existsSync(sock)).toBe(true); + }); + + it("daemon/stop responds then shuts down", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const result = await callDaemon<{ stopping: boolean }>( + "daemon/stop", + {}, + { socketPath: server.socketPath }, + ); + expect(result.stopping).toBe(true); + // Allow async stop to finish. + await new Promise((r) => setTimeout(r, 100)); + server = undefined; + }); + + it("accepts malformed NDJSON lines without crashing", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + await new Promise((resolve, reject) => { + const socket = net.createConnection(server!.socketPath); + let data = ""; + socket.on("data", (chunk) => { + data += String(chunk); + if (data.includes("invalid_request")) { + socket.on("error", () => {}); + socket.end(); + resolve(); + } + }); + socket.on("error", reject); + socket.write("not-json\n"); + }); + }); + + it("callDaemon maps error responses and unreachable sockets", async () => { + await expect( + callDaemon( + "ping", + {}, + { socketPath: path.join(freshDir(), "missing.sock") }, + ), + ).rejects.toThrow(CliExitCodeError); + + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + await expect( + callDaemon("sessions/use", {}, { socketPath: server.socketPath }), + ).rejects.toThrow(/requires a session name/); + }); + + it("callDaemon rejects malformed response JSON", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const bad = net.createServer((socket) => { + socket.on("error", () => {}); + socket.write("not-json\n"); + }); + await new Promise((resolve) => bad.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 2000 }), + ).rejects.toThrow(); + } finally { + bad.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("callDaemon ignores mismatched response ids then accepts a match", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const echo = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: "other", ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, ok: true, result: { ok: true } }) + "\n", + ); + }); + }); + await new Promise((resolve) => echo.listen(sock, resolve)); + try { + const result = await callDaemon<{ ok: boolean }>( + "ping", + {}, + { socketPath: sock, timeoutMs: 2000 }, + ); + expect(result.ok).toBe(true); + } finally { + echo.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("callDaemon skips blank lines and defaults missing exitCode", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const echo = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write("\n"); + socket.write( + JSON.stringify({ + id: req.id, + ok: false, + error: { code: "usage", message: "no exit" }, + }) + "\n", + ); + }); + }); + await new Promise((resolve) => echo.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 2000 }), + ).rejects.toMatchObject({ exitCode: 1 }); + } finally { + echo.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("stop() without start and with missing lock files is safe", async () => { + const d = freshDir(); + const orphan = new DaemonServer({ dir: d, idleMs: 0 }); + await orphan.stop("stop"); + + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + fs.unlinkSync(server.socketPath); + fs.unlinkSync(path.join(d, "daemon.lock")); + await server.stop("stop"); + server = undefined; + }); + + it("callDaemon times out a hung server", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const hung = net.createServer((socket) => { + socket.on("error", () => {}); + }); + await new Promise((resolve) => hung.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 100 }), + ).rejects.toThrow(/timed out/); + } finally { + hung.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }, 5000); + + it("sessions/use and reconnect replace an existing session", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s-again", + }); + expect(registry.use("s").serverIdentity).toBe("s-again"); + expect(() => registry.resolve("missing", false)).toThrow(/not found/); + await registry.disconnectAll(); + }); + + it("idle handler fires after last disconnect when idleMs > 0", async () => { + const registry = new SessionRegistry(20); + let idle = false; + registry.setIdleHandler(() => { + idle = true; + }); + const { command, args } = getTestMcpServerCommand(); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + await registry.disconnect("s", false); + await new Promise((r) => setTimeout(r, 60)); + expect(idle).toBe(true); + expect(registry.idleRemainingMs()).toBeNull(); + }); + + it("covers touch/auth/oauth-setup/disconnect-swallow/reconnect-before-idle", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + registry.touch("missing"); + + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + const session = registry.resolve("s", false); + vi.spyOn(session.client, "disconnect").mockRejectedValueOnce( + new Error("teardown boom"), + ); + await expect(registry.disconnect("s", false)).resolves.toEqual({ + name: "s", + }); + expect(registry.getMruName()).toBeNull(); + + const { AuthRecoveryRequiredError } = + await import("@inspector/core/auth/challenge.js"); + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ); + await expect( + registry.connect({ + name: "auth", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "auth", + }), + ).rejects.toMatchObject({ exitCode: 3 }); + + // SDK token-exchange failure (empty redirectUrl / stale store) must surface + // as auth_required so the front-end can re-prompt — not a hard ErrorEnvelope. + vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce( + new Error( + "Either provider.prepareTokenRequest() or authorizationCode is required", + ), + ); + await expect( + registry.connect({ + name: "reauth", + serverConfig: { + type: "streamable-http", + url: "https://example.com/mcp", + }, + serverIdentity: "reauth", + }), + ).rejects.toMatchObject({ + exitCode: 3, + envelope: { code: "auth_required" }, + }); + + await expect( + registry.connect({ + name: "http", + serverConfig: { + type: "streamable-http", + url: "http://127.0.0.1:1/mcp", + }, + serverIdentity: "http", + }), + ).rejects.toThrow(); + + const idleReg = new SessionRegistry(80); + const onIdle = vi.fn(); + idleReg.setIdleHandler(onIdle); + await idleReg.connect({ + name: "a", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "a", + }); + await idleReg.disconnect("a", false); + await idleReg.connect({ + name: "b", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "b", + }); + await new Promise((r) => setTimeout(r, 100)); + expect(onIdle).not.toHaveBeenCalled(); + await idleReg.disconnectAll(); + }, 20000); + + it("ensureDaemon reuses a running daemon and resolveDaemonScriptPath finds build", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(false); + expect(ensured.socketPath).toBe(server.socketPath); + }); + + it("ensureDaemon auto-spawns when no daemon is present", async () => { + const d = freshDir(); + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(true); + await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); + await new Promise((r) => setTimeout(r, 150)); + }); + + it("ensureDaemon replaces a stale accepting socket", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const stale = net.createServer((socket) => { + socket.on("error", () => {}); + socket.end(); + }); + await new Promise((resolve) => stale.listen(sock, resolve)); + try { + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(true); + await callDaemon("ping", {}, { socketPath: ensured.socketPath }); + await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); + await new Promise((r) => setTimeout(r, 150)); + } finally { + stale.close(); + } + }); + + it("session-less start arms idle and self-reaps", async () => { + const d = freshDir(); + let shut = false; + server = new DaemonServer({ + dir: d, + idleMs: 40, + onShutdown: () => { + shut = true; + }, + }); + await server.start(); + // ensureDaemon from tools/list with no sessions must not leak forever. + expect(server.registry.idleRemainingMs()).not.toBeNull(); + await new Promise((r) => setTimeout(r, 100)); + expect(shut).toBe(true); + server = undefined; + }); + + it("connect failure for a dead stdio command is surfaced and re-arms idle", async () => { + const registry = new SessionRegistry(5_000); + let idle = false; + registry.setIdleHandler(() => { + idle = true; + }); + await expect( + registry.connect({ + name: "dead", + serverConfig: { + type: "stdio", + command: path.join(os.tmpdir(), "no-such-mcp-server-binary"), + args: [], + }, + serverIdentity: "dead", + }), + ).rejects.toThrow(); + expect(registry.idleRemainingMs()).not.toBeNull(); + expect(idle).toBe(false); + }); + + it("re-arms idle when createSessionClient fails before client.connect", async () => { + const registry = new SessionRegistry(5_000); + registry.setIdleHandler(() => {}); + const prev = process.env.MCP_OAUTH_CALLBACK_URL; + process.env.MCP_OAUTH_CALLBACK_URL = "https://example.com/oauth/callback"; + try { + await expect( + registry.connect({ + name: "http", + serverConfig: { + type: "streamable-http", + url: "http://127.0.0.1:1/mcp", + }, + serverIdentity: "http", + }), + ).rejects.toThrow(/http scheme|callback URL/i); + expect(registry.idleRemainingMs()).not.toBeNull(); + } finally { + if (prev === undefined) delete process.env.MCP_OAUTH_CALLBACK_URL; + else process.env.MCP_OAUTH_CALLBACK_URL = prev; + } + }); + + it("callDaemon fails immediately when the peer closes without a response", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const peer = net.createServer((socket) => { + socket.on("error", () => {}); + // Accept then FIN with no NDJSON reply. + socket.end(); + }); + await new Promise((resolve) => peer.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 60_000 }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + }); + } finally { + peer.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("sessions/use via handle and blank IPC lines", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 60_000 }); + await server.start(); + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + const used = await server.handle({ + id: "u", + op: "sessions/use", + params: { name: "s" }, + }); + expect(used.ok).toBe(true); + expect(server.registry.idleRemainingMs()).toBeNull(); + + // sessions/show over the same live session — exercises the full case + // body (serverInfo/protocolVersion/protocolEra/capabilities lookups) + // in-process, where coverage instrumentation can see it. + const shown = await server.handle({ + id: "s2", + op: "sessions/show", + params: { name: "s" }, + }); + expect(shown.ok).toBe(true); + if (shown.ok) { + const result = shown.result as { protocolVersion?: string }; + expect(result.protocolVersion).toBeTruthy(); + } + + await new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.on("error", reject); + socket.connect(server!.socketPath, () => { + socket.write("\n\n"); + socket.end(); + resolve(); + }); + }); + + await callDaemon( + "disconnect", + { name: "s" }, + { socketPath: server.socketPath }, + ); + // Idle timer armed — remaining countdown is positive and ≤ configured idleMs. + const remaining = server.registry.idleRemainingMs(); + expect(remaining).not.toBeNull(); + expect(remaining!).toBeLessThanOrEqual(60_000); + expect(remaining!).toBeGreaterThan(0); + }); +}); + +describe("mcp session coverage", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // ignore + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-cov-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("covers sessions/use, daemon status, @session connect, and stop no-op", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const stopIdle = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopIdle); + expect(stopIdle.stdout).toContain("not running"); + + const connected = await runMcp( + [ + "connect", + "@alpha", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + expect(JSON.parse(connected.stdout).name).toBe("alpha"); + + const used = await runMcp(["sessions/use", "@alpha", "--format", "text"], { + env: e, + }); + expectCliSuccess(used); + expect(used.stdout).toContain("alpha"); + + const status = await runMcp(["daemon", "status"], { env: e }); + expectCliSuccess(status); + + const listed = await runMcp(["sessions/list"], { env: e }); + expectCliSuccess(listed); + + const viaServer = await runMcp( + [ + "connect", + "--server", + "test-stdio", + "--config", + configPath, + "--session", + "via-flag", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(viaServer); + + const stopped = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopped); + expect(stopped.stdout).toContain("stopping"); + }); + + it("rejects connect with no target and invalid --format", async () => { + const e = env(); + const missing = await runMcp(["connect"], { env: e }); + expectCliFailure(missing); + + const badFormat = await runMcp(["servers/list", "--format", "xml"], { + env: e, + }); + expectCliFailure(badFormat); + + const badTransport = await runMcp(["connect", "x", "--transport", "ftp"], { + env: e, + }); + expectCliFailure(badTransport); + + const badTimeout = await runMcp( + ["connect", "x", "--connect-timeout", "-1"], + { env: e }, + ); + expectCliFailure(badTimeout); + + const emptyUse = await runMcp(["sessions/use", ""], { env: e }); + expectCliFailure(emptyUse); + }); + + it("connects an ad-hoc stdio target", async () => { + const { command, args } = getTestMcpServerCommand(); + const e = env(); + // Multi-token positional target → ad-hoc (not a catalog entry name). + const result = await runMcp( + [ + "connect", + "--session", + "adhoc", + "--transport", + "stdio", + "--format", + "json", + command, + ...args, + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(result); + expect(JSON.parse(result.stdout).name).toBe("adhoc"); + }); + + it("treats a URL positional as ad-hoc", async () => { + const e = env(); + const result = await runMcp( + [ + "connect", + "http://127.0.0.1:9/mcp", + "--session", + "url", + "--connect-timeout", + "100", + "--format", + "json", + ], + { env: e, timeout: 10000 }, + ); + // Connection should fail (nothing listening) but the ad-hoc URL path ran. + expectCliFailure(result); + }); + + it("requires explicit session in non-interactive mode without opt-in", async () => { + configPath = createSampleTestConfig(); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-")); + const e = { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + // no MCP_ALLOW_DEFAULT_SESSION + }; + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + // Force requireExplicit by stubbing isTTY false is default in vitest forks. + const disc = await runMcp(["disconnect", "--format", "json"], { env: e }); + expectCliFailure(disc); + expect(disc.stderr).toMatch(/Explicit|--session|non-interactive/i); + + await runMcp(["disconnect", "--session", "test-stdio"], { env: e }); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts new file mode 100644 index 0000000000..5636493bd6 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-paths.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + createPrivateDaemonDir, + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, + getInspectorHome, +} from "../src/daemon/paths.js"; +import { writeFormattedResult } from "@inspector/cli/handlers/format-output.js"; + +describe("daemon paths", () => { + const backup: Record = {}; + + afterEach(() => { + for (const key of ["MCP_INSPECTOR_DAEMON_DIR", "MCP_STORAGE_DIR", "HOME"]) { + if (key in backup) { + if (backup[key] === undefined) delete process.env[key]; + else process.env[key] = backup[key]; + delete backup[key]; + } + } + }); + + function setEnv(key: string, value: string | undefined) { + backup[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + it("prefers MCP_INSPECTOR_DAEMON_DIR over MCP_STORAGE_DIR", () => { + const a = path.join(os.tmpdir(), "daemon-a"); + const b = path.join(os.tmpdir(), "daemon-b"); + setEnv("MCP_STORAGE_DIR", b); + setEnv("MCP_INSPECTOR_DAEMON_DIR", a); + expect(getDaemonDir()).toBe(path.resolve(a)); + expect(getDaemonSocketPath()).toBe( + path.join(path.resolve(a), "daemon.sock"), + ); + expect(getDaemonLockPath()).toBe(path.join(path.resolve(a), "daemon.lock")); + }); + + it("falls back to MCP_STORAGE_DIR then ~/.mcp-inspector", () => { + const storage = path.join(os.tmpdir(), "daemon-storage"); + setEnv("MCP_INSPECTOR_DAEMON_DIR", undefined); + setEnv("MCP_STORAGE_DIR", storage); + expect(getDaemonDir()).toBe(path.resolve(storage)); + setEnv("MCP_STORAGE_DIR", undefined); + expect(getDaemonDir()).toContain(".mcp-inspector"); + }); + + it("creates the daemon directory", () => { + const dir = path.join(os.tmpdir(), `daemon-mkdir-${Date.now()}`); + ensureDaemonDir(dir); + expect(fs.statSync(dir).isDirectory()).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("createPrivateDaemonDir nests under ~/.mcp-inspector/private", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-")); + setEnv("HOME", home); + setEnv("MCP_INSPECTOR_DAEMON_DIR", undefined); + setEnv("MCP_STORAGE_DIR", undefined); + expect(getInspectorHome()).toBe(path.join(home, ".mcp-inspector")); + const dir = createPrivateDaemonDir(); + expect(dir.startsWith(path.join(home, ".mcp-inspector", "private"))).toBe( + true, + ); + expect(fs.statSync(dir).isDirectory()).toBe(true); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); + +describe("writeFormattedResult", () => { + it("writes text and json envelopes", async () => { + let out = ""; + const original = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + out += String(chunk); + const cb = rest.find((x) => typeof x === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + try { + await writeFormattedResult({ ok: 1 }, "text"); + expect(out).toContain('"ok": 1'); + out = ""; + await writeFormattedResult({ ok: 2 }, "json"); + expect(JSON.parse(out)).toEqual({ result: { ok: 2 } }); + } finally { + process.stdout.write = original; + } + }); +}); diff --git a/clients/mcpi/__tests__/daemon-private.test.ts b/clients/mcpi/__tests__/daemon-private.test.ts new file mode 100644 index 0000000000..d36f9cc35c --- /dev/null +++ b/clients/mcpi/__tests__/daemon-private.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { assertDaemonToken, tokensEqual } from "../src/daemon/auth.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { ensureDaemon } from "../src/daemon/ensure.js"; +import { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, +} from "../src/daemon/paths.js"; +import { DaemonServer } from "../src/daemon/server.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + createPrivateBinding, + formatPrivateEnvExports, +} from "../src/session/private-env.js"; + +describe("daemon IPC token", () => { + it("compares tokens in constant time", () => { + expect(tokensEqual("abc", "abc")).toBe(true); + expect(tokensEqual("abc", "abd")).toBe(false); + expect(tokensEqual("abc", "ab")).toBe(false); + expect(tokensEqual(undefined, "x")).toBe(false); + }); + + it("assertDaemonToken allows shared mode and rejects bad private tokens", () => { + expect(() => assertDaemonToken(undefined, undefined)).not.toThrow(); + expect(() => assertDaemonToken(undefined, "x")).not.toThrow(); + expect(() => assertDaemonToken("secret", "secret")).not.toThrow(); + expect(() => assertDaemonToken("secret", "nope")).toThrow(CliExitCodeError); + expect(() => assertDaemonToken("secret", undefined)).toThrow( + CliExitCodeError, + ); + }); +}); + +describe("mcpi private", () => { + let home: string | undefined; + let prevHome: string | undefined; + + afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + prevHome = undefined; + if (home) { + fs.rmSync(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function useTempHome() { + prevHome = process.env.HOME; + home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-")); + process.env.HOME = home; + } + + it("prints shell exports for a new private binding", async () => { + useTempHome(); + const result = await runMcp(["private"], { + env: { HOME: home! }, + }); + expectCliSuccess(result); + expect(result.stdout).toMatch( + new RegExp(`export ${DAEMON_DIR_ENV}='[^']+/private/[^']+'`), + ); + expect(result.stdout).toMatch( + new RegExp(`export ${DAEMON_TOKEN_ENV}='[^']+'`), + ); + const dirMatch = result.stdout.match( + new RegExp(`${DAEMON_DIR_ENV}='([^']+)'`), + ); + expect(dirMatch?.[1]).toBeTruthy(); + expect(fs.statSync(dirMatch![1]!).isDirectory()).toBe(true); + }); + + it("formatPrivateEnvExports escapes single quotes", () => { + const text = formatPrivateEnvExports({ + dir: "/tmp/o'brian", + token: "t'ok", + }); + expect(text).toContain(`'/tmp/o'\\''brian'`); + expect(text).toContain(`'t'\\''ok'`); + }); + + it("createPrivateBinding allocates under private/", () => { + useTempHome(); + const binding = createPrivateBinding(); + expect(binding.dir).toContain(`${path.sep}private${path.sep}`); + expect(binding.dir.startsWith(home!)).toBe(true); + expect(binding.token.length).toBeGreaterThan(20); + }); +}); + +describe("private daemon end-to-end", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("rejects IPC without the required token and accepts with it", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-")); + const token = "test-token-value"; + server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); + await server.start(); + + await expect( + callDaemon( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 2000 }, + ), + ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 2000, token }, + ); + expect(pong.pong).toBe(true); + }); + + it("session front-end rethrows non-unreachable daemon errors", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-")); + const token = "good-token"; + server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); + await server.start(); + + const env = { + MCP_STORAGE_DIR: dir, + [DAEMON_DIR_ENV]: dir, + [DAEMON_TOKEN_ENV]: "wrong-token", + }; + + const listed = await runMcp(["sessions/list"], { env }); + expectCliFailure(listed); + expect(listed.stderr).toMatch(/authentication failed|daemon_auth_failed/i); + + const status = await runMcp(["daemon", "status"], { env }); + expectCliFailure(status); + + const configPath = createSampleTestConfig(); + try { + const servers = await runMcp(["servers/list", "--config", configPath], { + env, + }); + // Optional daemon probe must not swallow auth failures as empty sessions. + expectCliFailure(servers); + } finally { + deleteConfigFile(configPath); + } + }); + + it("ensureDaemon spawns a token-gated daemon from env", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-spawn-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + dir = createPrivateDaemonDir(); + const token = "spawn-token-xyz"; + const prevDir = process.env[DAEMON_DIR_ENV]; + const prevTok = process.env[DAEMON_TOKEN_ENV]; + process.env[DAEMON_DIR_ENV] = dir; + process.env[DAEMON_TOKEN_ENV] = token; + try { + const { socketPath, spawned } = await ensureDaemon({ dir, token }); + expect(spawned).toBe(true); + + // Explicit wrong token — do not rely on clearing env (callDaemon + // falls back to MCP_INSPECTOR_DAEMON_TOKEN when options.token omitted). + await expect( + callDaemon( + "ping", + {}, + { socketPath, timeoutMs: 2000, token: "wrong" }, + ), + ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath, timeoutMs: 2000, token }, + ); + expect(pong.pong).toBe(true); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }, + { socketPath, timeoutMs: 15000, token }, + ); + await callDaemon("daemon/stop", {}, { socketPath, token }); + } finally { + if (prevDir === undefined) delete process.env[DAEMON_DIR_ENV]; + else process.env[DAEMON_DIR_ENV] = prevDir; + if (prevTok === undefined) delete process.env[DAEMON_TOKEN_ENV]; + else process.env[DAEMON_TOKEN_ENV] = prevTok; + } + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/clients/mcpi/__tests__/daemon-sessions.test.ts b/clients/mcpi/__tests__/daemon-sessions.test.ts new file mode 100644 index 0000000000..b6937823ae --- /dev/null +++ b/clients/mcpi/__tests__/daemon-sessions.test.ts @@ -0,0 +1,537 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { DaemonServer } from "../src/daemon/server.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { parseRequestLine, encodeResponse } from "../src/daemon/framing.js"; +import { + DEFAULT_IDLE_MS, + elicitCapabilityToClientOption, + getLiveSessionAuthInfo, + getSessionAuthInfo, + isSessionAuthRequiredError, + SessionRegistry, +} from "../src/daemon/sessions.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; + +describe("daemon framing", () => { + it("parses and rejects invalid request lines", () => { + expect(parseRequestLine("")).toBeNull(); + expect(parseRequestLine(" ")).toBeNull(); + expect(parseRequestLine('{"id":"1","op":"ping"}')).toEqual({ + id: "1", + op: "ping", + }); + expect(() => parseRequestLine("not-json")).toThrow(); + expect(() => parseRequestLine('{"op":"ping"}')).toThrow(/Invalid daemon/); + expect(encodeResponse({ id: "1", ok: true, result: { pong: true } })).toBe( + '{"id":"1","ok":true,"result":{"pong":true}}\n', + ); + }); +}); + +describe("elicitCapabilityToClientOption", () => { + it("maps each elicitCapability mode to the InspectorClient elicit shape", () => { + expect(elicitCapabilityToClientOption("off")).toBe(false); + expect(elicitCapabilityToClientOption("url")).toEqual({ url: true }); + expect(elicitCapabilityToClientOption("form")).toEqual({ form: true }); + expect(elicitCapabilityToClientOption("both")).toEqual({ + url: true, + form: true, + }); + }); + + it("defaults to both (url+form) when unset, matching the pre-#1783 hardcoded default", () => { + expect(elicitCapabilityToClientOption(undefined)).toEqual({ + url: true, + form: true, + }); + }); +}); + +describe("isSessionAuthRequiredError", () => { + it("treats EMA client misconfiguration as auth_required (front-end maps it to guidance)", async () => { + const { EmaClientNotConfiguredError } = + await import("@inspector/core/auth/ema/clientConfigError.js"); + expect( + isSessionAuthRequiredError( + new EmaClientNotConfiguredError("not_configured"), + ), + ).toBe(true); + }); + + it("recognizes unauthorized, recovery, and SDK token-exchange failures", () => { + expect(isSessionAuthRequiredError(new Error("nope"))).toBe(false); + expect( + isSessionAuthRequiredError( + new AuthRecoveryRequiredError(new URL("https://as.example/a"), { + reason: "unauthorized", + }), + ), + ).toBe(true); + const unauthorized = Object.assign(new Error("boom"), { status: 401 }); + expect(isSessionAuthRequiredError(unauthorized)).toBe(true); + expect( + isSessionAuthRequiredError( + new Error( + "Either provider.prepareTokenRequest() or authorizationCode is required", + ), + ), + ).toBe(true); + expect( + isSessionAuthRequiredError( + new Error("redirectUrl is required for authorization_code flow"), + ), + ).toBe(true); + expect( + isSessionAuthRequiredError( + new Error("No code verifier saved for session"), + ), + ).toBe(true); + }); +}); + +describe("getSessionAuthInfo", () => { + const clientWith = ( + getOAuthState: () => Promise, + ): Parameters[0] => + ({ getOAuthState }) as unknown as Parameters[0]; + + it("is undefined for no-auth sessions and when the state read fails", async () => { + expect( + await getSessionAuthInfo(clientWith(async () => undefined)), + ).toBeUndefined(); + expect( + await getSessionAuthInfo( + clientWith(async () => { + throw new Error("storage unavailable"); + }), + ), + ).toBeUndefined(); + }); + + it("projects standard OAuth state (scope + clientId when present)", async () => { + expect( + await getSessionAuthInfo( + clientWith(async () => ({ + authorized: true, + protocol: "standard", + serverUrl: "https://mcp.example", + grantedScope: "mcp:tools", + client: { clientId: "client-123", hasClientSecret: false }, + })), + ), + ).toEqual({ + method: "oauth", + authorized: true, + scope: "mcp:tools", + clientId: "client-123", + }); + }); + + it("projects EMA state with IdP session and omits absent optionals", async () => { + expect( + await getSessionAuthInfo( + clientWith(async () => ({ + authorized: false, + protocol: "ema", + serverUrl: "https://mcp.example", + ema: { + idpIssuer: "https://idp.example", + idpClientId: "idp-client", + idpSession: "logged_in", + }, + })), + ), + ).toEqual({ method: "ema", authorized: false, idpSession: "logged_in" }); + }); +}); + +describe("getLiveSessionAuthInfo", () => { + it("is undefined for stdio, malformed http configs, and unengaged OAuth", async () => { + const { resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-live-auth-")); + const saved = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + const savedClient = process.env.MCP_CLIENT_CONFIG_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(dir, "oauth.json"); + process.env.MCP_CLIENT_CONFIG_PATH = path.join(dir, "client.json"); + resetNodeOAuthStorageCache(); + try { + expect( + await getLiveSessionAuthInfo({ + serverConfig: { type: "stdio", command: "x" }, + }), + ).toBeUndefined(); + // Defensive: OAuth-capable type without a usable url. + expect( + await getLiveSessionAuthInfo({ + serverConfig: { type: "streamable-http" } as never, + }), + ).toBeUndefined(); + // http server, no oauth config anywhere, empty storage: no snapshot. + expect( + await getLiveSessionAuthInfo({ + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + }), + ).toBeUndefined(); + // Corrupt oauth.json: the disk read fails, and the best-effort catch + // yields undefined rather than failing sessions/show. + fs.writeFileSync(process.env.MCP_INSPECTOR_OAUTH_STATE_PATH!, "{nope"); + resetNodeOAuthStorageCache(); + expect( + await getLiveSessionAuthInfo({ + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + }), + ).toBeUndefined(); + } finally { + if (saved === undefined) + delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = saved; + if (savedClient === undefined) delete process.env.MCP_CLIENT_CONFIG_PATH; + else process.env.MCP_CLIENT_CONFIG_PATH = savedClient; + resetNodeOAuthStorageCache(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("SessionRegistry", () => { + it("requires an explicit session when asked", () => { + const registry = new SessionRegistry(0); + expect(() => registry.resolve(undefined, true)).toThrow(CliExitCodeError); + expect(() => registry.resolve(undefined, false)).toThrow( + /No open sessions/, + ); + }); + + it("tracks MRU across connect/disconnect", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + const a = await registry.connect({ + name: "a", + serverConfig: { type: "stdio", command, args }, + serverIdentity: `${command} ${args.join(" ")}`, + }); + expect(a.isMru).toBe(true); + // stdio transport: no OAuth, so no auth snapshot is reported. + expect(a.auth).toBeUndefined(); + const b = await registry.connect({ + name: "b", + serverConfig: { type: "stdio", command, args }, + serverIdentity: `${command} ${args.join(" ")}`, + }); + expect(b.isMru).toBe(true); + expect(registry.getMruName()).toBe("b"); + registry.use("a"); + expect(registry.getMruName()).toBe("a"); + await registry.disconnect("b", false); + expect(registry.list().map((s) => s.name)).toEqual(["a"]); + await registry.disconnect(undefined, false); + expect(registry.sessionCount()).toBe(0); + expect(DEFAULT_IDLE_MS).toBe(60_000); + }); + + it("reports the connect-time auth snapshot, and sessions/show recomputes from disk", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const { NodeOAuthStorage, resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + // Isolated client.json (EMA IdP config) + oauth.json so the show + // handler's disk read is deterministic. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-auth-info-")); + const savedEnv = { + MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, + MCP_INSPECTOR_OAUTH_STATE_PATH: + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH, + }; + process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json"); + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join( + stateDir, + "oauth.json", + ); + const issuer = "https://idp.example.com"; + fs.writeFileSync( + process.env.MCP_CLIENT_CONFIG_PATH, + JSON.stringify({ + enterpriseManagedAuth: { + enabled: true, + idp: { issuer, clientId: "idp-client" }, + }, + }), + ); + resetNodeOAuthStorageCache(); + // Unexpired unsigned JWT so the seeded IdP session reads as logged_in. + const b64 = (obj: object) => + Buffer.from(JSON.stringify(obj)).toString("base64url"); + const idToken = `${b64({ alg: "none" })}.${b64({ + exp: Math.floor(Date.now() / 1000) + 3600, + })}.sig`; + + // Force an auth snapshot onto the connect result without a live OAuth + // server, so the auth-present reporting paths (connect result, list, + // use) are exercised. + const stateSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue({ + authorized: true, + protocol: "ema", + serverUrl: "https://mcp.example.com/mcp", + ema: { + idpIssuer: issuer, + idpClientId: "idp-client", + idpSession: "logged_in", + }, + }); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValue(undefined); + const server = new DaemonServer({ + dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-auth-daemon-")), + idleMs: 0, + }); + const registry = server.registry; + try { + const info = await registry.connect({ + name: "a", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverSettings: { + headers: [], + metadata: {}, + env: [], + connectionTimeout: 30_000, + requestTimeout: 0, + taskTtl: 0, + maxFetchRequests: 0, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + enterpriseManaged: true, + }, + serverIdentity: "https://mcp.example.com/mcp", + }); + const expected = { + method: "ema", + authorized: true, + idpSession: "logged_in", + }; + expect(info.auth).toEqual(expected); + expect(registry.list()[0]?.auth).toEqual(expected); + expect(registry.use("a").auth).toEqual(expected); + + // sessions/show reads *disk*, not the client's memory-cached storage: + // seed an IdP session on disk and expect logged_in (no tokens were + // persisted, so authorized is false — matching auth/ema-status). + await new NodeOAuthStorage().saveIdpSession(issuer, { + idToken, + idTokenExpiresAt: Date.now() + 3600_000, + }); + const shown = await server.handle({ + id: "show", + op: "sessions/show", + params: { name: "a" }, + }); + expect(shown.ok).toBe(true); + if (!shown.ok) throw new Error("unreachable"); + expect((shown.result as { auth?: unknown }).auth).toEqual({ + method: "ema", + authorized: false, + idpSession: "logged_in", + }); + + // Simulate a cross-process logout (e.g. auth/ema-logout): clear the + // IdP session on disk. list keeps the connect-time value; show + // reflects the new disk state. + resetNodeOAuthStorageCache(); + await new NodeOAuthStorage().clearIdpSession(issuer); + expect(registry.list()[0]?.auth).toEqual(expected); + const loggedOut = await server.handle({ + id: "show2", + op: "sessions/show", + params: { name: "a" }, + }); + expect(loggedOut.ok).toBe(true); + if (!loggedOut.ok) throw new Error("unreachable"); + expect((loggedOut.result as { auth?: unknown }).auth).toEqual({ + method: "ema", + authorized: false, + idpSession: "none", + }); + } finally { + await registry.disconnectAll(); + stateSpy.mockRestore(); + connectSpy.mockRestore(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetNodeOAuthStorageCache(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); +}); + +describe("DaemonServer IPC", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("serves ping / connect / sessions/list / disconnect over the socket", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath: server.socketPath }, + ); + expect(pong.pong).toBe(true); + + const { command, args } = getTestMcpServerCommand(); + const connected = await callDaemon<{ name: string; isMru: boolean }>( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(connected.name).toBe("stdio"); + expect(connected.isMru).toBe(true); + + const listed = await callDaemon<{ sessions: { name: string }[] }>( + "sessions/list", + {}, + { socketPath: server.socketPath }, + ); + expect(listed.sessions.map((s) => s.name)).toEqual(["stdio"]); + + const status = await callDaemon<{ pid: number; socketPath: string }>( + "daemon/status", + {}, + { socketPath: server.socketPath }, + ); + expect(status.pid).toBe(process.pid); + expect(status.socketPath).toBe(server.socketPath); + + const disc = await callDaemon<{ name: string }>( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + expect(disc.name).toBe("stdio"); + }); + + it("runs rpc tools/list and initialize against a live session", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpc-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + + const listed = await callDaemon<{ + kind: string; + result: { tools: unknown[] }; + }>( + "rpc", + { method: "tools/list", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(listed.kind).toBe("result"); + expect(listed.result.tools.length).toBeGreaterThan(0); + + const init = await callDaemon<{ + kind: string; + result: { protocolVersion?: string }; + }>( + "rpc", + { method: "initialize", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(init.kind).toBe("result"); + expect(init.result.protocolVersion).toBeTruthy(); + + await callDaemon( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + }); + + it("rejects stream methods on rpc and rpc methods on stream", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-ops-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + + await expect( + callDaemon( + "rpc", + { method: "logging/tail", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 5000 }, + ), + ).rejects.toMatchObject({ envelope: { code: "use_stream_op" } }); + + const badStream = await server.handleOutcome({ + id: "s1", + op: "stream", + params: { method: "tools/list", name: "stdio" }, + }); + expect(badStream.response.ok).toBe(false); + + const noMethod = await server.handle({ + id: "s2", + op: "rpc", + params: { name: "stdio" } as never, + }); + expect(noMethod.ok).toBe(false); + + await callDaemon( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts new file mode 100644 index 0000000000..ddf3e9e751 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-stream.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { streamDaemon } from "../src/daemon/stream-client.js"; +import { + acceptDaemonConnection, + removeStaleDaemonSocket, +} from "../src/daemon/ipc-glue.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +describe("streamDaemon + ipc-glue", () => { + let dir: string | undefined; + let server: net.Server | undefined; + const sockets = new Set(); + + afterEach(async () => { + for (const s of sockets) { + s.destroy(); + } + sockets.clear(); + if (server) { + await new Promise((resolve) => { + server!.close(() => resolve()); + }); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + function freshSock(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stream-")); + return path.join(dir, "daemon.sock"); + } + + async function listen( + sock: string, + onSocket: (socket: net.Socket) => void, + ): Promise { + server = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + onSocket(socket); + }); + await new Promise((resolve) => server!.listen(sock, resolve)); + } + + it("delivers data frames then end (skips blank/mismatched ids)", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + // Mismatched first response id is ignored; matching ok opens the stream. + socket.write( + JSON.stringify({ id: "wrong", ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write("\n"); + socket.write( + JSON.stringify({ id: "other", stream: "data", data: { skip: 1 } }) + + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "noop", data: 0 }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n", + ); + socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n"); + }); + }); + + const data: unknown[] = []; + await streamDaemon( + { method: "logging/tail" }, + { socketPath: sock, timeoutMs: 5000, onData: (d) => data.push(d) }, + ); + expect(data).toEqual([{ n: 1 }]); + }); + + it("resolves on socket error after the stream has opened", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + setTimeout(() => socket.destroy(), 20); + }); + }); + await streamDaemon( + {}, + { socketPath: sock, timeoutMs: 2000, onData: () => {} }, + ); + }); + + it("rejects malformed stream frames after open", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write("not-a-frame\n"); + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toThrow(); + }); + + it("rejects error responses without exitCode (defaults USAGE)", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ + id: req.id, + ok: false, + error: { code: "usage", message: "nope" }, + }) + "\n", + ); + }); + }); + + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE }); + }); + + it("rejects malformed first-frame JSON", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", () => { + socket.write("not-json\n"); + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toThrow(); + }); + + it("aborts via signal after the stream opens", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + }); + }); + + const ac = new AbortController(); + const pending = streamDaemon( + {}, + { + socketPath: sock, + timeoutMs: 5000, + signal: ac.signal, + onData: () => {}, + }, + ); + await new Promise((r) => setTimeout(r, 50)); + ac.abort(); + await pending; + }); + + it("times out a hung stream open", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", () => { + // never respond with an ok frame + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 80, onData: () => {} }), + ).rejects.toThrow(/timed out/); + }, 5000); + + it("fails when the peer FINs before the stream ok frame", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end(); + }); + }); + await expect( + streamDaemon( + {}, + { socketPath: sock, timeoutMs: 60_000, onData: () => {} }, + ), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + }); + }); + + it("resolves when the peer closes mid-stream", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.end(); + }); + }); + await streamDaemon( + {}, + { socketPath: sock, timeoutMs: 2000, onData: () => {} }, + ); + }); + + it("removeStaleDaemonSocket handles absent, dead, and live sockets", async () => { + const sock = freshSock(); + await removeStaleDaemonSocket(sock); + + fs.writeFileSync(sock, ""); + await removeStaleDaemonSocket(sock); + expect(fs.existsSync(sock)).toBe(false); + + await listen(sock, () => {}); + await expect(removeStaleDaemonSocket(sock)).rejects.toThrow( + /already running/, + ); + }); + + it("acceptDaemonConnection rejects invalid request lines", async () => { + const sock = freshSock(); + const chunks: string[] = []; + await listen(sock, (socket) => { + acceptDaemonConnection(socket, async () => ({ + response: { id: "x", ok: true, result: {} }, + })); + }); + + await new Promise((resolve, reject) => { + const client = net.connect(sock, () => { + sockets.add(client); + client.on("data", (c) => chunks.push(String(c))); + client.write('{"op":"ping"}\n'); + setTimeout(() => { + client.destroy(); + resolve(); + }, 50); + }); + client.on("error", reject); + }); + expect(chunks.join("")).toContain("invalid_request"); + }); + + it("acceptDaemonConnection streams via startStream until socket closes", async () => { + const sock = freshSock(); + let stopCalled = false; + await listen(sock, (socket) => { + acceptDaemonConnection(socket, async (req) => ({ + response: { id: req.id, ok: true, result: {} }, + startStream: (writeData) => { + writeData({ a: 1 }); + return () => { + stopCalled = true; + throw new Error("unsubscribe boom"); + }; + }, + })); + }); + + const frames: string[] = []; + await new Promise((resolve) => { + const client = net.connect(sock, () => { + sockets.add(client); + client.on("data", (c) => frames.push(String(c))); + client.on("close", () => resolve()); + client.on("error", () => {}); + client.write( + JSON.stringify({ id: "s1", op: "stream", params: {} }) + "\n", + ); + // Half-close so the server cleanup can still write the end frame. + setTimeout(() => client.end(), 80); + }); + client.on("error", () => {}); + }); + const joined = frames.join(""); + expect(joined).toContain('"stream":"data"'); + expect(stopCalled).toBe(true); + }); + + it("unreachable socket path fails before streaming", async () => { + await expect( + streamDaemon( + {}, + { + socketPath: path.join(os.tmpdir(), "no-such-mcp-daemon.sock"), + timeoutMs: 500, + onData: () => {}, + }, + ), + ).rejects.toBeInstanceOf(CliExitCodeError); + }); +}); diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts new file mode 100644 index 0000000000..a037270757 --- /dev/null +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const callDaemon = vi.fn(); +const ensureDaemon = vi.fn(); +const streamDaemon = vi.fn(); +const promptElicitation = vi.fn(); + +vi.mock("../src/daemon/index.js", () => ({ + callDaemon: (...args: unknown[]) => callDaemon(...args), + ensureDaemon: (...args: unknown[]) => ensureDaemon(...args), + streamDaemon: (...args: unknown[]) => streamDaemon(...args), +})); + +vi.mock("../src/session/elicitation-prompt.js", () => ({ + promptElicitation: (...args: unknown[]) => promptElicitation(...args), +})); + +describe("dispatchSessionRpc", () => { + let stdout: string; + let originalWrite: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + originalWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + ensureDaemon.mockResolvedValue({ socketPath: "/tmp/t.sock" }); + callDaemon.mockReset(); + streamDaemon.mockReset(); + promptElicitation.mockReset(); + }); + + afterEach(() => { + process.stdout.write = originalWrite; + }); + + it("writes pretty JSON for --format json", async () => { + callDaemon.mockResolvedValue({ + kind: "result", + result: { tools: [] }, + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/list", + {}, + { format: "json", requireExplicit: false }, + ); + expect(JSON.parse(stdout.trim())).toEqual({ tools: [] }); + expect(stdout).toContain("\n"); + }); + + it("writes human text for tools/list by default", async () => { + callDaemon.mockResolvedValue({ + kind: "result", + result: { + tools: [{ name: "echo", description: "Echo", inputSchema: {} }], + }, + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc("tools/list", {}, { requireExplicit: false }); + expect(stdout).toContain("Tools (1):"); + expect(stdout).toContain("`echo"); + }); + + it("writes human app-info list for ndjson outcomes", async () => { + callDaemon.mockResolvedValue({ + kind: "ndjson", + lines: [{ hasApp: false, toolName: "a" }], + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/list", + { appInfo: true }, + { requireExplicit: false }, + ); + expect(stdout).toContain("App info"); + expect(stdout).toContain("`a`"); + }); + + it("opens a stream for logging/tail and wires SIGINT abort", async () => { + streamDaemon.mockImplementation( + async ( + _params: unknown, + opts: { onData: (d: unknown) => void; signal?: AbortSignal }, + ) => { + opts.onData({ + type: "subscribed", + uri: "test://x", + }); + process.emit("SIGINT"); + expect(opts.signal?.aborted).toBe(true); + }, + ); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "logging/tail", + {}, + { requireExplicit: false, session: "@s" }, + ); + expect(stdout).toContain("Subscribed:"); + expect(streamDaemon).toHaveBeenCalled(); + }); + + it("wires SIGINT/SIGTERM abort for the general rpc path (not just streams)", async () => { + callDaemon.mockImplementation( + async (_op: string, _params: unknown, opts: { signal?: AbortSignal }) => { + process.emit("SIGTERM"); + expect(opts.signal?.aborted).toBe(true); + return { kind: "result", result: {} }; + }, + ); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + expect(callDaemon).toHaveBeenCalled(); + }); + + it("removes the SIGINT/SIGTERM listeners after the rpc call settles", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const before = process.listenerCount("SIGINT"); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + expect(process.listenerCount("SIGINT")).toBe(before); + }); + + it("wires onElicitation as interactive when text format + TTY stdin/stdout", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + try { + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "text", requireExplicit: false }, + ); + const opts = callDaemon.mock.calls[0][2] as { + onElicitation: (frame: unknown) => unknown; + }; + expect(opts.onElicitation).toBeInstanceOf(Function); + promptElicitation.mockResolvedValue({ action: "cancel" }); + await opts.onElicitation({ id: "x" }); + expect(promptElicitation).toHaveBeenCalledWith( + { id: "x" }, + expect.objectContaining({ interactive: true }), + ); + } finally { + if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); + if (stdoutDesc) + Object.defineProperty(process.stdout, "isTTY", stdoutDesc); + } + }); + + it("wires onElicitation as non-interactive for --format json", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + const opts = callDaemon.mock.calls[0][2] as { + onElicitation: (frame: unknown) => unknown; + }; + promptElicitation.mockResolvedValue({ action: "cancel" }); + await opts.onElicitation({ id: "x" }); + expect(promptElicitation).toHaveBeenCalledWith( + { id: "x" }, + expect.objectContaining({ interactive: false }), + ); + }); +}); + +describe("hoistAtSession / stripAt / requireExplicitSession", () => { + it("stripAt removes leading @", async () => { + const { stripAt, requireExplicitSession } = + await import("../src/session/dispatch.js"); + expect(stripAt("@x")).toBe("x"); + expect(stripAt(undefined)).toBeUndefined(); + const prev = process.env.MCP_ALLOW_DEFAULT_SESSION; + process.env.MCP_ALLOW_DEFAULT_SESSION = "1"; + expect(requireExplicitSession()).toBe(false); + if (prev === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; + else process.env.MCP_ALLOW_DEFAULT_SESSION = prev; + }); + + it("requireExplicitSession keys off stdin TTY (piping stdout still OK)", async () => { + const { requireExplicitSession } = + await import("../src/session/dispatch.js"); + const prevEnv = process.env.MCP_ALLOW_DEFAULT_SESSION; + delete process.env.MCP_ALLOW_DEFAULT_SESSION; + const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + try { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + expect(requireExplicitSession()).toBe(false); + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + expect(requireExplicitSession()).toBe(true); + } finally { + if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); + else + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: undefined, + }); + if (stdoutDesc) + Object.defineProperty(process.stdout, "isTTY", stdoutDesc); + else + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: undefined, + }); + if (prevEnv === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; + else process.env.MCP_ALLOW_DEFAULT_SESSION = prevEnv; + } + }); +}); diff --git a/clients/mcpi/__tests__/elicitation-bridge.test.ts b/clients/mcpi/__tests__/elicitation-bridge.test.ts new file mode 100644 index 0000000000..0e9b5ef4a9 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-bridge.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from "vitest"; +import { wireElicitationBridge } from "../src/daemon/elicitation-bridge.js"; +import type { ElicitationChannel } from "../src/daemon/ipc-glue.js"; +import type { ElicitationResponseFrame } from "../src/daemon/protocol.js"; +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +/** + * Covers `wireElicitationBridge`'s event routing: origin filtering + * (task-input-required elicitations are left for a future tasks/-based + * command, not answered here), URL vs form mode frame shaping, and the + * channel-failure fallback to `cancel()` (since some construction sites, + * notably legacy URL-mode, never wire a reject callback). + */ +function fakeClient(): { + client: InspectorClient; + emit: (detail: unknown) => void; +} { + const target = new EventTarget(); + const client = { + addEventListener: (type: string, listener: EventListener) => + target.addEventListener(type, listener), + removeEventListener: (type: string, listener: EventListener) => + target.removeEventListener(type, listener), + } as unknown as InspectorClient; + return { + client, + emit: (detail: unknown) => + target.dispatchEvent( + new CustomEvent("newPendingElicitation", { detail }), + ), + }; +} + +function fakeMessage(overrides: Partial> = {}) { + return { + id: "elicitation-x", + origin: "server-request", + request: { method: "elicitation/create", params: { message: "hi" } }, + respond: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn(), + reject: vi.fn(), + ...overrides, + }; +} + +describe("wireElicitationBridge", () => { + it("skips task-input-required origin elicitations entirely", () => { + const { client, emit } = fakeClient(); + const channel: ElicitationChannel = { request: vi.fn() }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ origin: "task-input-required" }); + emit(message); + expect(channel.request).not.toHaveBeenCalled(); + expect(message.respond).not.toHaveBeenCalled(); + unwire(); + }); + + it("builds a url-mode frame and responds with the channel's answer", async () => { + const { client, emit } = fakeClient(); + const answer: ElicitationResponseFrame = { + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-x", + action: "accept", + }; + const request = vi.fn().mockResolvedValue(answer); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ + request: { + method: "elicitation/create", + params: { message: "Please visit", url: "https://example.com" }, + }, + }); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "elicitation-request", + mode: "url", + url: "https://example.com", + elicitationId: "elicitation-x", + origin: "server-request", + }), + ); + expect(message.respond).toHaveBeenCalledWith({ + action: "accept", + content: undefined, + }); + unwire(); + }); + + it("builds a form-mode frame with requestedSchema", async () => { + const { client, emit } = fakeClient(); + const answer: ElicitationResponseFrame = { + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-x", + action: "decline", + }; + const request = vi.fn().mockResolvedValue(answer); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ + request: { + method: "elicitation/create", + params: { + message: "Confirm?", + requestedSchema: { type: "object", properties: {} }, + }, + }, + }); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "form", + requestedSchema: { type: "object", properties: {} }, + url: undefined, + }), + ); + expect(message.respond).toHaveBeenCalledWith({ + action: "decline", + content: undefined, + }); + unwire(); + }); + + it("cancels the pending elicitation when the channel rejects", async () => { + const { client, emit } = fakeClient(); + const request = vi.fn().mockRejectedValue(new Error("disconnected")); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage(); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(message.cancel).toHaveBeenCalled(); + expect(message.respond).not.toHaveBeenCalled(); + unwire(); + }); + + it("processes multiple elicitations in arrival order (serialized)", async () => { + const { client, emit } = fakeClient(); + const order: string[] = []; + const request = vi.fn().mockImplementation(async (frame) => { + order.push(`start:${frame.elicitationId}`); + await Promise.resolve(); + order.push(`end:${frame.elicitationId}`); + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + } satisfies ElicitationResponseFrame; + }); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + emit(fakeMessage({ id: "e1" })); + emit(fakeMessage({ id: "e2" })); + await vi.waitFor(() => { + expect(order).toEqual(["start:e1", "end:e1", "start:e2", "end:e2"]); + }); + + unwire(); + }); + + it("unwire stops the listener from reacting to further events", () => { + const { client, emit } = fakeClient(); + const channel: ElicitationChannel = { request: vi.fn() }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + unwire(); + emit(fakeMessage()); + expect(channel.request).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/mcpi/__tests__/elicitation-client.test.ts b/clients/mcpi/__tests__/elicitation-client.test.ts new file mode 100644 index 0000000000..37bcb2ad95 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-client.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { callDaemon } from "../src/daemon/client.js"; +import type { + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "../src/daemon/protocol.js"; + +/** + * Covers `callDaemon`'s duplex elicitation handling (dual-era support, phase + * 1): a mid-`rpc` `elicitation-request` frame arriving before the final + * response, answered via `onElicitation` (or auto-cancelled without one), + * with the connect timeout cleared once the exchange starts. + */ +describe("callDaemon elicitation duplex", () => { + let dir: string | undefined; + let server: net.Server | undefined; + const sockets = new Set(); + + afterEach(async () => { + for (const s of sockets) s.destroy(); + sockets.clear(); + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + function freshSock(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-client-")); + return path.join(dir, "daemon.sock"); + } + + async function listen( + sock: string, + onSocket: (socket: net.Socket) => void, + ): Promise { + server = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + onSocket(socket); + }); + await new Promise((resolve) => server!.listen(sock, resolve)); + } + + it("routes an elicitation-request frame to onElicitation and writes its answer", async () => { + const sock = freshSock(); + let receivedAnswer: ElicitationResponseFrame | undefined; + await listen(sock, (socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") { + receivedAnswer = msg as ElicitationResponseFrame; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + continue; + } + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + const seenFrames: ElicitationRequestFrame[] = []; + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { + socketPath: sock, + timeoutMs: 5000, + onElicitation: async (frame) => { + seenFrames.push(frame); + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + }; + }, + }, + ); + + expect(result).toEqual({ done: true }); + expect(seenFrames).toHaveLength(1); + expect(seenFrames[0].mode).toBe("url"); + expect(seenFrames[0].url).toBe("https://example.com/confirm"); + expect(receivedAnswer?.action).toBe("accept"); + expect(receivedAnswer?.elicitationId).toBe("elicitation-1"); + }); + + it("auto-cancels when no onElicitation callback is provided", async () => { + const sock = freshSock(); + let receivedAnswer: ElicitationResponseFrame | undefined; + await listen(sock, (socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") { + receivedAnswer = msg as ElicitationResponseFrame; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + continue; + } + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-2", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "input-required", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000 }, + ); + + expect(result).toEqual({ done: true }); + expect(receivedAnswer?.action).toBe("cancel"); + expect(receivedAnswer?.elicitationId).toBe("elicitation-2"); + }); + + it("ignores an elicitation-request frame whose id doesn't match this call", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + let buffer = ""; + let answered = false; + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string }; + if (!answered) { + answered = true; + const frame: ElicitationRequestFrame = { + id: "not-this-call", + kind: "elicitation-request", + elicitationId: "elicitation-3", + mode: "url", + message: "stray frame", + url: "https://example.com", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + } + } + }); + }); + + const onElicitation = async () => + ({ + id: "n/a", + kind: "elicitation-response", + elicitationId: "n/a", + action: "cancel", + }) satisfies ElicitationResponseFrame; + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000, onElicitation }, + ); + expect(result).toEqual({ done: true }); + }); + + it("fails the call if onElicitation itself throws", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") continue; + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-4", + mode: "url", + message: "boom", + url: "https://example.com", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + await expect( + callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { + socketPath: sock, + timeoutMs: 5000, + onElicitation: async () => { + throw new Error("prompt blew up"); + }, + }, + ), + ).rejects.toThrow("prompt blew up"); + }); + + it("fails with a clear cancellation error when the abort signal fires mid-call", async () => { + const sock = freshSock(); + await listen(sock, () => { + // Never respond — the call should hang until aborted, not until + // timeoutMs, proving the signal (not the timeout) ended it. + }); + + const ac = new AbortController(); + const promise = callDaemon( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 60_000, signal: ac.signal }, + ); + ac.abort(); + await expect(promise).rejects.toThrow("cancelled"); + }); + + it("silently swallows a post-settle socket error (e.g. late ECONNRESET)", async () => { + const sock = freshSock(); + let serverSocket: net.Socket | undefined; + await listen(sock, (socket) => { + serverSocket = socket; + let buffer = ""; + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string }; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + } + }); + }); + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000 }, + ); + expect(result).toEqual({ done: true }); + // Force a client-side 'error' after the call already settled; the + // no-op listener installed by settle() must swallow it without + // rethrowing or crashing the test. + serverSocket?.destroy(new Error("late reset")); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); +}); diff --git a/clients/mcpi/__tests__/elicitation-prompt.test.ts b/clients/mcpi/__tests__/elicitation-prompt.test.ts new file mode 100644 index 0000000000..0977592450 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-prompt.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createStyle } from "@inspector/cli/style.js"; +import type { ElicitationRequestFrame } from "../src/daemon/protocol.js"; + +const question = vi.fn(); +const close = vi.fn(); +const promptFormMock = vi.fn(); + +const once = vi.fn(); + +vi.mock("node:readline/promises", () => ({ + createInterface: () => ({ question, close, once }), +})); + +vi.mock("../src/session/form-prompt.js", async () => { + const actual = await vi.importActual< + typeof import("../src/session/form-prompt.js") + >("../src/session/form-prompt.js"); + return { + promptForm: (...args: unknown[]) => promptFormMock(...args), + watchForClose: actual.watchForClose, + }; +}); + +/** + * Covers `promptElicitation`'s terminal UI: form mode always declines + * (rendering isn't built yet), non-interactive callers auto-cancel with a + * clear message instead of hanging, and interactive URL mode reads the + * user's accept/cancel choice. + */ +describe("promptElicitation", () => { + let stderr: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + question.mockReset(); + close.mockReset(); + once.mockReset(); + promptFormMock.mockReset(); + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + const style = createStyle(false); + + function urlFrame( + overrides: Partial = {}, + ): ElicitationRequestFrame { + return { + id: "req-1", + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "server-request", + ...overrides, + }; + } + + function formFrame( + overrides: Partial = {}, + ): ElicitationRequestFrame { + return { + id: "req-1", + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "form", + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + origin: "server-request", + ...overrides, + }; + } + + it("declines form-mode elicitations whose schema isn't the restricted primitive shape", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame({ mode: "form", url: undefined }); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "decline", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("doesn't support"); + }); + + it("declines form-mode elicitations non-interactively without prompting", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame({ + mode: "form", + url: undefined, + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "decline", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("--format json"); + }); + + it("cancels when the caller isn't interactive (e.g. --format json) without prompting", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "cancel", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("--format json"); + }); + + it("cancels non-interactively without a url line when the frame has none", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame({ url: undefined }); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer.action).toBe("cancel"); + expect(stderr).not.toContain("undefined"); + }); + + it("accepts when the interactive user confirms completion", async () => { + question.mockResolvedValue(""); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "accept", + }); + expect(close).toHaveBeenCalled(); + expect(stderr).toContain("Please confirm"); + expect(stderr).toContain("https://example.com/confirm"); + }); + + it("cancels when the interactive user types 'c'", async () => { + question.mockResolvedValue("c"); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + }); + + it("falls back to cancel if reading input throws", async () => { + question.mockRejectedValue(new Error("stdin closed")); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); + + it("cancels URL mode if stdin closes before the user answers", async () => { + // Simulates a non-TTY stdin (e.g. an agent-driven pipe) hitting EOF + // before an answer arrives: question() hangs, but the "close" listener + // registered via watchForClose() fires and wins the race. + question.mockImplementation(() => new Promise(() => {})); + once.mockImplementation((event: string, cb: () => void) => { + if (event === "close") cb(); + }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); + + it("accepts an interactive form submission and returns its content", async () => { + promptFormMock.mockResolvedValue({ + action: "accept", + content: { name: "octocat" }, + }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "accept", + content: { name: "octocat" }, + }); + expect(close).toHaveBeenCalled(); + }); + + it("declines an interactive form when promptForm reports decline", async () => { + promptFormMock.mockResolvedValue({ action: "decline" }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("decline"); + }); + + it("cancels an interactive form when promptForm reports cancel", async () => { + promptFormMock.mockResolvedValue({ action: "cancel" }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + }); + + it("falls back to cancel if promptForm throws", async () => { + promptFormMock.mockRejectedValue(new Error("stdin closed")); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/clients/mcpi/__tests__/ema-commands.test.ts b/clients/mcpi/__tests__/ema-commands.test.ts new file mode 100644 index 0000000000..7c67b55c8b --- /dev/null +++ b/clients/mcpi/__tests__/ema-commands.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { PLAIN } from "@inspector/cli/style.js"; +import { formatEmaStatusHuman } from "../src/session/format-human.js"; + +const getEmaStatus = vi.fn(); +const emaLogin = vi.fn(); +const emaLogout = vi.fn(); + +vi.mock("../src/session/ema.js", () => ({ + getEmaStatus: (...args: unknown[]) => getEmaStatus(...args), + emaLogin: (...args: unknown[]) => emaLogin(...args), + emaLogout: (...args: unknown[]) => emaLogout(...args), +})); + +describe("auth/ema-* commands", () => { + let stdout: string; + let originalStdoutWrite: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + originalStdoutWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + getEmaStatus.mockReset(); + emaLogin.mockReset(); + emaLogout.mockReset(); + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + }); + + it("auth/ema-status prints the status as JSON", async () => { + getEmaStatus.mockResolvedValue({ + clientConfigPath: "/tmp/client.json", + configured: true, + enabled: true, + issuer: "https://idp.example.com", + clientId: "idp-client", + loginState: "logged_in", + }); + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp(["node", "mcpi", "auth/ema-status", "--format", "json"]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.issuer).toBe("https://idp.example.com"); + expect(parsed.loginState).toBe("logged_in"); + }); + + it("auth/ema-status prints a human summary in text mode", async () => { + getEmaStatus.mockResolvedValue({ + clientConfigPath: "/tmp/client.json", + configured: true, + enabled: true, + issuer: "https://idp.example.com", + clientId: "idp-client", + loginState: "none", + }); + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp(["node", "mcpi", "auth/ema-status"]); + expect(stdout).toContain("EMA (enterprise-managed auth):"); + expect(stdout).toContain("https://idp.example.com"); + expect(stdout).toContain("IdP session: none"); + }); + + it("auth/ema-login forwards --relogin and prints the outcome", async () => { + emaLogin.mockResolvedValue({ + issuer: "https://idp.example.com", + loginState: "logged_in", + alreadyLoggedIn: false, + }); + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp(["node", "mcpi", "auth/ema-login", "--relogin"]); + expect(emaLogin).toHaveBeenCalledWith({ relogin: true }); + expect(stdout).toContain("Signed in"); + expect(stdout).toContain("https://idp.example.com"); + }); + + it("auth/ema-login reports an already-active session", async () => { + emaLogin.mockResolvedValue({ + issuer: "https://idp.example.com", + loginState: "logged_in", + alreadyLoggedIn: true, + }); + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp(["node", "mcpi", "auth/ema-login"]); + expect(emaLogin).toHaveBeenCalledWith({ relogin: false }); + expect(stdout).toContain("Already signed in"); + }); + + it("auth/ema-logout prints the signed-out issuer", async () => { + emaLogout.mockResolvedValue({ issuer: "https://idp.example.com" }); + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp(["node", "mcpi", "auth/ema-logout"]); + expect(stdout).toContain("Signed out"); + expect(stdout).toContain("https://idp.example.com"); + }); +}); + +describe("formatEmaStatusHuman", () => { + it("renders the unconfigured state with configuration pointers", () => { + const text = formatEmaStatusHuman( + { clientConfigPath: "/tmp/client.json", configured: false }, + PLAIN, + ); + expect(text).toContain("not configured"); + expect(text).toContain("/tmp/client.json"); + }); + + it("renders a configured, disabled IdP without a clientId", () => { + const text = formatEmaStatusHuman( + { + clientConfigPath: "/tmp/client.json", + configured: true, + enabled: false, + issuer: "https://idp.example.com", + loginState: "expired", + }, + PLAIN, + ); + expect(text).toContain("https://idp.example.com"); + expect(text).toContain("Enabled: no"); + expect(text).toContain("IdP session: expired"); + expect(text).not.toContain("client:"); + }); + + it("highlights a live IdP session and defaults missing fields", () => { + const loggedIn = formatEmaStatusHuman( + { + clientConfigPath: "/tmp/client.json", + configured: true, + enabled: true, + issuer: "https://idp.example.com", + clientId: "idp-client", + loginState: "logged_in", + }, + PLAIN, + ); + expect(loggedIn).toContain("IdP session: logged_in"); + expect(loggedIn).toContain("(client: idp-client)"); + + // Defensive fallbacks when a JSON payload omits optional fields. + const sparse = formatEmaStatusHuman({ configured: true }, PLAIN); + expect(sparse).toContain("IdP: `?`"); + expect(sparse).toContain("IdP session: none"); + }); +}); diff --git a/clients/mcpi/__tests__/ema.test.ts b/clients/mcpi/__tests__/ema.test.ts new file mode 100644 index 0000000000..149d23d44b --- /dev/null +++ b/clients/mcpi/__tests__/ema.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; + +const runRunnerInteractiveOAuth = vi.fn(); +const startIdpOidcAuthorization = vi.fn(); +const completeIdpOidcAuthorization = vi.fn(); + +vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runRunnerInteractiveOAuth: (...args: unknown[]) => + runRunnerInteractiveOAuth(...args), + }; +}); + +vi.mock("@inspector/core/auth/ema/idpOidc.js", () => ({ + startIdpOidcAuthorization: (...args: unknown[]) => + startIdpOidcAuthorization(...args), + completeIdpOidcAuthorization: (...args: unknown[]) => + completeIdpOidcAuthorization(...args), +})); + +const ISSUER = "https://idp.example.com"; + +/** Unexpired unsigned JWT ({ exp } one hour out). */ +function fakeIdToken(): string { + const b64 = (obj: object) => + Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64({ + exp: Math.floor(Date.now() / 1000) + 3600, + })}.sig`; +} + +describe("mcpi ema helpers", () => { + let dir: string; + let savedEnv: Record; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-ema-")); + savedEnv = { + MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, + MCP_INSPECTOR_OAUTH_STATE_PATH: + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH, + }; + process.env.MCP_CLIENT_CONFIG_PATH = path.join(dir, "client.json"); + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(dir, "oauth.json"); + resetNodeOAuthStorageCache(); + runRunnerInteractiveOAuth.mockReset(); + startIdpOidcAuthorization.mockReset(); + completeIdpOidcAuthorization.mockReset(); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetNodeOAuthStorageCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function writeClientConfig(config: unknown): void { + fs.writeFileSync( + process.env.MCP_CLIENT_CONFIG_PATH!, + JSON.stringify(config), + ); + } + + function emaClientConfig(enabled?: boolean): unknown { + return { + enterpriseManagedAuth: { + ...(enabled !== undefined && { enabled }), + idp: { + issuer: ISSUER, + clientId: "idp-client", + clientSecret: "idp-secret", + }, + }, + }; + } + + async function seedIdpSession(): Promise { + const storage = new NodeOAuthStorage(); + await storage.saveIdpSession(ISSUER, { + idToken: fakeIdToken(), + idTokenExpiresAt: Date.now() + 3600_000, + }); + } + + it("getEmaStatus reports unconfigured when client.json has no EMA block", async () => { + const { getEmaStatus } = await import("../src/session/ema.js"); + const status = await getEmaStatus(); + expect(status.configured).toBe(false); + expect(status.enabled).toBe(false); + expect(status.loginState).toBe("unconfigured"); + expect(status.clientConfigPath).toBe(process.env.MCP_CLIENT_CONFIG_PATH); + }); + + it("getEmaStatus reports configured+enabled with no IdP session as 'none'", async () => { + writeClientConfig(emaClientConfig()); + const { getEmaStatus } = await import("../src/session/ema.js"); + const status = await getEmaStatus(); + expect(status.configured).toBe(true); + expect(status.enabled).toBe(true); + expect(status.issuer).toBe(ISSUER); + expect(status.clientId).toBe("idp-client"); + expect(status.loginState).toBe("none"); + }); + + it("getEmaStatus reports a disabled config (still shows issuer + session state)", async () => { + writeClientConfig(emaClientConfig(false)); + await seedIdpSession(); + const { getEmaStatus } = await import("../src/session/ema.js"); + const status = await getEmaStatus(); + expect(status.configured).toBe(true); + expect(status.enabled).toBe(false); + expect(status.loginState).toBe("logged_in"); + }); + + it("emaLogin fails with actionable guidance when EMA is not configured", async () => { + const { emaLogin } = await import("../src/session/ema.js"); + await expect(emaLogin()).rejects.toThrow( + /not configured.*client settings/is, + ); + await expect(emaLogin()).rejects.toThrow( + process.env.MCP_CLIENT_CONFIG_PATH!, + ); + }); + + it("emaLogin fails with actionable guidance when EMA is disabled", async () => { + writeClientConfig(emaClientConfig(false)); + const { emaLogin } = await import("../src/session/ema.js"); + await expect(emaLogin()).rejects.toThrow(/disabled/i); + }); + + it("emaLogout fails when EMA is not configured", async () => { + const { emaLogout } = await import("../src/session/ema.js"); + await expect(emaLogout()).rejects.toThrow(CliExitCodeError); + }); + + it("emaLogout works even when EMA is disabled, and clears the IdP session", async () => { + writeClientConfig(emaClientConfig(false)); + await seedIdpSession(); + const { emaLogout, getEmaStatus } = await import("../src/session/ema.js"); + const result = await emaLogout(); + expect(result.issuer).toBe(ISSUER); + expect((await getEmaStatus()).loginState).toBe("none"); + }); + + it("emaLogin short-circuits when already signed in", async () => { + writeClientConfig(emaClientConfig()); + await seedIdpSession(); + const { emaLogin } = await import("../src/session/ema.js"); + const result = await emaLogin(); + expect(result).toEqual({ + issuer: ISSUER, + loginState: "logged_in", + alreadyLoggedIn: true, + }); + expect(runRunnerInteractiveOAuth).not.toHaveBeenCalled(); + }); + + it("emaLogin runs the IdP flow via the runner adapter and reports the new session", async () => { + writeClientConfig(emaClientConfig()); + let stderr = ""; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + + startIdpOidcAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://idp.example.com/authorize?x=1"), + }); + completeIdpOidcAuthorization.mockImplementation(async () => { + await seedIdpSession(); + return { idToken: fakeIdToken() }; + }); + runRunnerInteractiveOAuth.mockImplementation( + async (options: { + client: { + authenticate: () => Promise; + completeOAuthFlow: (code: string, iss?: string) => Promise; + }; + redirectUrlProvider: { redirectUrl: string }; + }) => { + // Mirror the real runner: bind the loopback redirect before leg 1. + options.redirectUrlProvider.redirectUrl = + "http://127.0.0.1:45678/oauth/callback"; + const url = await options.client.authenticate(); + expect(url?.href).toContain("idp.example.com/authorize"); + await options.client.completeOAuthFlow("code-1", ISSUER); + return { kind: "success" }; + }, + ); + + try { + const { emaLogin } = await import("../src/session/ema.js"); + const result = await emaLogin(); + expect(result).toEqual({ + issuer: ISSUER, + loginState: "logged_in", + alreadyLoggedIn: false, + }); + } finally { + process.stderr.write = originalWrite; + } + + expect(startIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUrl: "http://127.0.0.1:45678/oauth/callback", + }), + ); + expect(completeIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ authorizationCode: "code-1", iss: ISSUER }), + ); + // Agent-attended wording: vitest's stderr is not a TTY, so the printed + // line must direct an agent to relay the IdP link to the human user. + expect(stderr).toContain( + "The user needs to sign in to the enterprise identity provider", + ); + }); + + it("emaLogin --relogin clears the existing session and re-runs the flow", async () => { + writeClientConfig(emaClientConfig()); + await seedIdpSession(); + startIdpOidcAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://idp.example.com/authorize"), + }); + completeIdpOidcAuthorization.mockImplementation(async () => { + await seedIdpSession(); + return { idToken: fakeIdToken() }; + }); + runRunnerInteractiveOAuth.mockImplementation( + async (options: { + client: { + authenticate: () => Promise; + completeOAuthFlow: (code: string) => Promise; + }; + redirectUrlProvider: { redirectUrl: string }; + }) => { + // The pre-existing session must already be gone before leg 1 runs. + const storage = new NodeOAuthStorage(); + expect(await storage.getIdpSession(ISSUER)).toBeUndefined(); + await options.client.authenticate(); + await options.client.completeOAuthFlow("code-2"); + return { kind: "success" }; + }, + ); + + const { emaLogin } = await import("../src/session/ema.js"); + const result = await emaLogin({ relogin: true }); + expect(result.alreadyLoggedIn).toBe(false); + expect(result.loginState).toBe("logged_in"); + expect(runRunnerInteractiveOAuth).toHaveBeenCalledOnce(); + }); + + it("mcpiEmaGuidance names both configuration routes", async () => { + const { mcpiEmaGuidance } = await import("../src/session/ema.js"); + expect(mcpiEmaGuidance("not_configured")).toMatch( + /Client Settings.*enterpriseManagedAuth/is, + ); + expect(mcpiEmaGuidance("disabled")).toContain("enabled"); + }); +}); diff --git a/clients/mcpi/__tests__/form-prompt.test.ts b/clients/mcpi/__tests__/form-prompt.test.ts new file mode 100644 index 0000000000..9b77a75f4d --- /dev/null +++ b/clients/mcpi/__tests__/form-prompt.test.ts @@ -0,0 +1,399 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createStyle } from "@inspector/cli/style.js"; +import { promptForm } from "../src/session/form-prompt.js"; +import type { FormField } from "../src/session/form-schema.js"; + +/** + * Covers `promptForm`'s field-by-field prompting (one branch per + * `FormField.kind`, including validation retry loops and defaults) and the + * review step (submit / edit-by-name / cancel). + */ +describe("promptForm", () => { + let stderr: string; + let originalWrite: typeof process.stderr.write; + const style = createStyle(false); + + beforeEach(() => { + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + function fakeRl(answers: string[]) { + let i = 0; + const closeHandlers: Array<() => void> = []; + return { + question: vi.fn(async () => { + const answer = answers[i]; + i += 1; + if (answer === undefined) { + throw new Error("no more scripted answers"); + } + return answer; + }), + once: vi.fn((event: string, cb: () => void) => { + if (event === "close") closeHandlers.push(cb); + }), + // Test-only hook: simulates the underlying stdin closing (e.g. a + // redirected/piped input hitting EOF) so we can exercise the + // watchForClose() race without a real stream. + __triggerClose: () => closeHandlers.forEach((cb) => cb()), + } as unknown as Parameters[0] & { + __triggerClose: () => void; + }; + } + + const stringField: FormField = { + name: "name", + required: true, + title: "Name", + kind: "string", + }; + + it("collects a required string field and submits on blank review answer", async () => { + const rl = fakeRl(["octocat", ""]); + const outcome = await promptForm( + rl, + "Enter your name", + [stringField], + style, + ); + expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } }); + expect(stderr).toContain("Enter your name"); + }); + + it("re-prompts a required string field left blank, then accepts a default", async () => { + const field: FormField = { + ...stringField, + required: false, + default: "anon", + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "anon" } }); + }); + + it("omits an optional string field left blank with no default", async () => { + const field: FormField = { ...stringField, required: false }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("re-prompts a required string field until non-blank", async () => { + const rl = fakeRl(["", "octocat", ""]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } }); + expect(stderr).toContain("This field is required"); + }); + + it("enforces minLength/maxLength on a string field", async () => { + const field: FormField = { ...stringField, minLength: 3, maxLength: 5 }; + const rl = fakeRl(["ab", "toolong", "oka", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "oka" } }); + expect(stderr).toContain("at least 3"); + expect(stderr).toContain("at most 5"); + }); + + it("collects a required number field with range validation", async () => { + const field: FormField = { + name: "age", + required: true, + title: "Age", + kind: "number", + integer: false, + minimum: 18, + maximum: 100, + }; + const rl = fakeRl(["notanumber", "5", "30", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { age: 30 } }); + expect(stderr).toContain("Enter a valid number"); + }); + + it("rejects a non-integer value for an integer field", async () => { + const field: FormField = { + name: "count", + required: true, + title: "Count", + kind: "number", + integer: true, + }; + const rl = fakeRl(["1.5", "3", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { count: 3 } }); + expect(stderr).toContain("Enter a valid integer"); + }); + + it("uses a number field's default on blank, or omits when optional with none", async () => { + const withDefault: FormField = { + name: "age", + required: false, + title: "Age", + kind: "number", + integer: false, + default: 21, + }; + const rl1 = fakeRl(["", ""]); + expect(await promptForm(rl1, "msg", [withDefault], style)).toEqual({ + action: "accept", + content: { age: 21 }, + }); + + const noDefault: FormField = { + name: "age", + required: false, + title: "Age", + kind: "number", + integer: false, + }; + const rl2 = fakeRl(["", ""]); + expect(await promptForm(rl2, "msg", [noDefault], style)).toEqual({ + action: "accept", + content: {}, + }); + }); + + it("re-prompts a required number field left blank", async () => { + const field: FormField = { + name: "age", + required: true, + title: "Age", + kind: "number", + integer: false, + }; + const rl = fakeRl(["", "42", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { age: 42 } }); + }); + + it("collects a boolean field via y/n, defaulting on blank", async () => { + const field: FormField = { + name: "confirm", + required: false, + title: "Confirm", + kind: "boolean", + default: true, + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { confirm: true } }); + }); + + it("re-prompts on an invalid boolean answer and accepts yes/no variants", async () => { + const field: FormField = { + name: "confirm", + required: true, + title: "Confirm", + kind: "boolean", + }; + const rl = fakeRl(["maybe", "yes", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { confirm: true } }); + expect(stderr).toContain("Please answer y or n"); + + const rl2 = fakeRl(["no", ""]); + expect( + await promptForm(rl2, "msg", [{ ...field, required: false }], style), + ).toEqual({ action: "accept", content: { confirm: false } }); + }); + + it("omits an optional boolean field left blank with no default", async () => { + const field: FormField = { + name: "confirm", + required: false, + title: "Confirm", + kind: "boolean", + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("collects a single-select enum by number, and accepts a default on blank", async () => { + const field: FormField = { + name: "color", + required: true, + title: "Color", + kind: "enum", + choices: [ + { value: "red", label: "Red" }, + { value: "blue", label: "Blue" }, + ], + }; + const rl = fakeRl(["2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { color: "blue" } }); + + const withDefault: FormField = { ...field, default: "red" }; + const rl2 = fakeRl(["", ""]); + expect(await promptForm(rl2, "msg", [withDefault], style)).toEqual({ + action: "accept", + content: { color: "red" }, + }); + }); + + it("re-prompts on an out-of-range enum choice and a required blank", async () => { + const field: FormField = { + name: "color", + required: true, + title: "Color", + kind: "enum", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", "9", "1", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { color: "red" } }); + expect(stderr).toContain("This field is required"); + expect(stderr).toContain("Enter a number between 1 and 1"); + }); + + it("omits an optional enum field left blank with no default", async () => { + const field: FormField = { + name: "color", + required: false, + title: "Color", + kind: "enum", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("collects a multi-select enum via comma-separated numbers, enforcing minItems/maxItems", async () => { + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "blue", label: "Blue" }, + ], + minItems: 1, + maxItems: 2, + }; + const rl = fakeRl(["1,2,3", "1,2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { colors: ["red", "green"] }, + }); + expect(stderr).toContain("Select at most 2"); + }); + + it("enforces minItems on a multi-select enum", async () => { + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + ], + minItems: 2, + }; + const rl = fakeRl(["1", "1,2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { colors: ["red", "green"] }, + }); + expect(stderr).toContain("Select at least 2"); + }); + + it("uses a multi-select default on blank, formatted in the field description", async () => { + const field: FormField = { + name: "colors", + required: false, + title: "Colors", + kind: "multiselect", + choices: [{ value: "red", label: "Red" }], + default: ["red"], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { colors: ["red"] } }); + expect( + (rl.question as ReturnType).mock.calls[0][0], + ).toContain("[default: red]"); + }); + + it("omits an optional multi-select field left blank with no default", async () => { + const field: FormField = { + name: "colors", + required: false, + title: "Colors", + kind: "multiselect", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("shows a description when the field has one", async () => { + const field: FormField = { + ...stringField, + description: "Your full display name", + }; + const rl = fakeRl(["octocat", ""]); + await promptForm(rl, "msg", [field], style); + expect( + (rl.question as ReturnType).mock.calls[0][0], + ).toContain("Your full display name"); + }); + + it("cancels from the review step", async () => { + const rl = fakeRl(["octocat", "c"]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "cancel" }); + }); + + it("re-prompts a review answer that doesn't name a known field", async () => { + const rl = fakeRl(["octocat", "bogus", "c"]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "cancel" }); + expect(stderr).toContain('Unknown field "bogus"'); + }); + + it("lets the review step re-edit a named field before submitting", async () => { + const rl = fakeRl(["octocat", "name", "edited", ""]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "accept", content: { name: "edited" } }); + }); + + it("shows '(none)' in the review for a field with no value", async () => { + const field: FormField = { ...stringField, required: false }; + const rl = fakeRl(["", ""]); + await promptForm(rl, "msg", [field], style); + expect(stderr).toContain("(none)"); + }); + + it("rejects instead of hanging when stdin closes before an answer arrives", async () => { + const rl = fakeRl([]); + (rl.question as ReturnType).mockImplementation( + () => new Promise(() => {}), // never resolves on its own + ); + const outcome = promptForm(rl, "msg", [stringField], style); + (rl as unknown as { __triggerClose: () => void }).__triggerClose(); + await expect(outcome).rejects.toThrow( + "stdin closed before an answer was given", + ); + }); +}); diff --git a/clients/mcpi/__tests__/form-schema.test.ts b/clients/mcpi/__tests__/form-schema.test.ts new file mode 100644 index 0000000000..facee5b402 --- /dev/null +++ b/clients/mcpi/__tests__/form-schema.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect } from "vitest"; +import { parseFormSchema } from "../src/session/form-schema.js"; + +describe("parseFormSchema", () => { + it("returns null for a non-object schema", () => { + expect(parseFormSchema(undefined)).toBeNull(); + expect(parseFormSchema({ type: "string" })).toBeNull(); + }); + + it("returns null when properties is missing or not an object", () => { + expect(parseFormSchema({ type: "object" })).toBeNull(); + expect(parseFormSchema({ type: "object", properties: "nope" })).toBeNull(); + }); + + it("parses a string field with title/description/length/format/default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + name: { + type: "string", + title: "Display Name", + description: "Your name", + minLength: 2, + maxLength: 20, + format: "email", + default: "octocat", + }, + }, + required: ["name"], + }); + expect(fields).toEqual([ + { + name: "name", + required: true, + title: "Display Name", + description: "Your name", + kind: "string", + minLength: 2, + maxLength: 20, + format: "email", + default: "octocat", + }, + ]); + }); + + it("parses a number field, distinguishing integer from number", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + age: { type: "number", minimum: 18, maximum: 100, default: 30 }, + count: { type: "integer" }, + }, + properties2: undefined, + } as Record); + expect(fields).toEqual([ + { + name: "age", + required: false, + title: "age", + description: undefined, + kind: "number", + integer: false, + minimum: 18, + maximum: 100, + default: 30, + }, + { + name: "count", + required: false, + title: "count", + description: undefined, + kind: "number", + integer: true, + minimum: undefined, + maximum: undefined, + default: undefined, + }, + ]); + }); + + it("parses a boolean field with a default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { confirm: { type: "boolean", default: false } }, + }); + expect(fields).toEqual([ + { + name: "confirm", + required: false, + title: "confirm", + description: undefined, + kind: "boolean", + default: false, + }, + ]); + }); + + it("parses a single-select enum without titles", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + color: { + type: "string", + title: "Color", + enum: ["Red", "Green", "Blue"], + default: "Red", + }, + }, + }); + expect(fields).toEqual([ + { + name: "color", + required: false, + title: "Color", + description: undefined, + kind: "enum", + choices: [ + { value: "Red", label: "Red" }, + { value: "Green", label: "Green" }, + { value: "Blue", label: "Blue" }, + ], + default: "Red", + }, + ]); + }); + + it("parses a single-select enum with titled oneOf", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + color: { + type: "string", + oneOf: [{ const: "#FF0000", title: "Red" }, { const: "#00FF00" }], + }, + }, + }); + expect(fields).toEqual([ + { + name: "color", + required: false, + title: "color", + description: undefined, + kind: "enum", + choices: [ + { value: "#FF0000", label: "Red" }, + { value: "#00FF00", label: "#00FF00" }, + ], + default: undefined, + }, + ]); + }); + + it("returns null when oneOf entries are malformed", () => { + expect( + parseFormSchema({ + type: "object", + properties: { + color: { type: "string", oneOf: [{ notConst: true }] }, + }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { color: { type: "string", oneOf: "nope" } }, + }), + ).toBeNull(); + }); + + it("parses a multi-select enum without titles, with min/maxItems and default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + title: "Colors", + minItems: 1, + maxItems: 2, + items: { type: "string", enum: ["Red", "Green", "Blue"] }, + default: ["Red", "Green"], + }, + }, + }); + expect(fields).toEqual([ + { + name: "colors", + required: false, + title: "Colors", + description: undefined, + kind: "multiselect", + choices: [ + { value: "Red", label: "Red" }, + { value: "Green", label: "Green" }, + { value: "Blue", label: "Blue" }, + ], + minItems: 1, + maxItems: 2, + default: ["Red", "Green"], + }, + ]); + }); + + it("parses a multi-select enum with titled anyOf", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + items: { + anyOf: [ + { const: "#FF0000", title: "Red" }, + { const: "#00FF00", title: "Green" }, + ], + }, + }, + }, + }); + expect(fields?.[0]).toMatchObject({ + kind: "multiselect", + choices: [ + { value: "#FF0000", label: "Red" }, + { value: "#00FF00", label: "Green" }, + ], + }); + }); + + it("returns null for an array field without items or without enum/anyOf", () => { + expect( + parseFormSchema({ + type: "object", + properties: { colors: { type: "array" } }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { + colors: { type: "array", items: { type: "string" } }, + }, + }), + ).toBeNull(); + }); + + it("ignores a non-string-array default on a multiselect field", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + items: { type: "string", enum: ["Red"] }, + default: [1, 2], + }, + }, + }); + expect(fields?.[0]).toMatchObject({ default: undefined }); + }); + + it("returns null for an unsupported/unknown property type", () => { + expect( + parseFormSchema({ + type: "object", + properties: { nested: { type: "object", properties: {} } }, + }), + ).toBeNull(); + }); + + it("returns null when a property isn't an object", () => { + expect( + parseFormSchema({ + type: "object", + properties: { name: "not-a-schema" }, + }), + ).toBeNull(); + }); + + it("treats non-array/malformed required as no required fields", () => { + const fields = parseFormSchema({ + type: "object", + properties: { name: { type: "string" } }, + required: "name", + }); + expect(fields?.[0].required).toBe(false); + }); +}); diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts new file mode 100644 index 0000000000..15fc07e1de --- /dev/null +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -0,0 +1,852 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + formatCallToolResultHuman, + formatToolsHuman, + formatResourcesHuman, + formatResourceTemplatesHuman, + formatPromptsHuman, + formatResourceReadHuman, + formatPromptResultHuman, + formatCompletionsHuman, + formatTasksHuman, + formatTaskHuman, + formatInitializeHuman, + formatRootsHuman, + formatAuthListHuman, + formatServersListHuman, + formatServerShowHuman, + formatSessionsListHuman, + formatSessionInfoHuman, + formatAppInfoListHuman, + formatAppInfoHuman, + formatSkillVerifyListHuman, + formatStreamEventHuman, + formatRpcResultHuman, +} from "../src/session/format-human.js"; +import { writeSessionOutput } from "../src/session/format-session.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { createStyle } from "@inspector/cli/style.js"; + +describe("format-human", () => { + it("formats tools with schema variants and empty list", () => { + expect(formatToolsHuman([])).toContain("(none)"); + const text = formatToolsHuman([ + { + name: "echo", + description: "Echo back\nmore", + inputSchema: { + type: "object", + properties: { + message: { type: "string" }, + n: { type: "number" }, + tags: { type: "array", items: { type: "string" } }, + extra: { type: "boolean" }, + }, + required: ["message"], + }, + annotations: { + readOnlyHint: true, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: "types", + inputSchema: { + type: "object", + properties: { + emptyArr: { type: "array" }, + multi: { type: ["string", "number"] }, + bare: {}, + }, + }, + }, + { + name: "more", + inputSchema: { + type: "object", + properties: { + flag: { type: ["boolean", "null"] }, + choice: { enum: ["a", "b"] }, + obj: { type: "object" }, + }, + }, + }, + { + name: "ints", + inputSchema: { + type: "object", + properties: { + i: { type: "integer" }, + unknownType: { type: "custom" }, + nonObjProp: "x", + }, + }, + annotations: {}, + }, + { name: "plain", inputSchema: null }, + { name: "emptyProps", inputSchema: { type: "object", properties: {} } }, + { name: "noProps", inputSchema: { type: "object" } }, + {}, + ]); + expect(text).toContain("Tools (8):"); + expect(text).toContain("`echo(message:str, n?:num, tags?:[str], …)`"); + expect(text).toContain("[read-only, destructive, idempotent, open-world]"); + expect(text).toContain("emptyArr?:[any]"); + expect(text).toContain("multi?:str | num"); + expect(text).toContain("bare?:any"); + expect(text).toContain("flag?:bool"); + expect(text).toContain("choice?:enum"); + expect(text).toContain("`plain()`"); + expect(text).toContain("`?()`"); + }); + + it("formats list helpers for resources, templates, prompts, roots, tasks", () => { + expect( + formatResourcesHuman([ + { name: "r", uri: "u://x", description: "d\n2" }, + { uri: "u://only" }, + { name: "n", uri: 1, description: " " }, + { name: "no-uri" }, + ]), + ).toContain("`r` (u://x)"); + expect(formatResourcesHuman([])).toContain("(none)"); + + expect( + formatResourceTemplatesHuman([ + { name: "t", uriTemplate: "u://{id}", description: "tpl" }, + { description: " " }, + { name: "x", uriTemplate: 1 }, + ]), + ).toContain("u://{id}"); + expect(formatResourceTemplatesHuman([])).toContain("(none)"); + + expect( + formatPromptsHuman([ + { name: "p", description: "hi\nmore" }, + { description: " " }, + {}, + ]), + ).toContain("`p`"); + expect(formatPromptsHuman([])).toContain("(none)"); + + expect( + formatRootsHuman([{ uri: "file:///a", name: "a" }, { uri: "file:///b" }]), + ).toContain("file:///a (a)"); + expect(formatRootsHuman([])).toContain("(none)"); + + expect( + formatTasksHuman([ + { taskId: "1", status: "running", statusMessage: "go" }, + { id: "2", status: "done" }, + {}, + ]), + ).toContain("`1` running"); + expect(formatTasksHuman([])).toContain("(none)"); + + expect( + formatTaskHuman({ + taskId: "t1", + status: "ok", + statusMessage: "fine", + createdAt: "c", + lastUpdatedAt: "u", + }), + ).toContain("Created: c"); + expect(formatTaskHuman(null)).toContain("Task: `?`"); + expect(formatTaskHuman({})).toContain("Status: ?"); + }); + + it("formats call tool results across content block types", () => { + const structured = { ok: true }; + const withDupe = formatCallToolResultHuman({ + isError: true, + content: [ + { type: "text", text: JSON.stringify(structured) }, + { type: "text", text: "hello" }, + { type: "text", text: "{not-json" }, + { + type: "resource_link", + uri: "u://r", + name: "n", + description: "d", + mimeType: "text/plain", + }, + { type: "image", mimeType: "image/png", data: "abc" }, + { type: "audio", mimeType: "audio/wav" }, + { + type: "resource", + resource: { uri: "u://e", mimeType: "text/plain", text: "body" }, + }, + { type: "custom", x: 1 }, + ], + structuredContent: structured, + _meta: { a: 1 }, + }); + expect(withDupe).toContain("Tool error:"); + expect(withDupe).toContain("hello"); + expect(withDupe).toContain("Resource link"); + expect(withDupe).toContain("[Image:"); + expect(withDupe).toContain("[Audio:"); + expect(withDupe).toContain("Embedded resource"); + expect(withDupe).toContain('"x": 1'); + expect(withDupe).not.toContain("Structured content:"); + + expect( + formatCallToolResultHuman({ + isError: true, + structuredContent: { only: true }, + content: [], + }), + ).toContain("Structured content:"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "image" }, { type: "audio", data: "x" }], + }), + ).toContain("[Image: unknown"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "resource" }], + }), + ).toContain("Embedded resource"); + + expect( + formatCallToolResultHuman({ + content: [ + { + type: "resource", + resource: { uri: "u://e" }, + }, + ], + }), + ).toContain("URI: u://e"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "resource_link", uri: "u" }], + }), + ).toContain("Resource link"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "text" }], + structuredContent: {}, + _meta: {}, + }), + ).toContain("Content:"); + + expect(formatCallToolResultHuman({})).toBe("(no content)"); + }); + + it("formats resource read, prompt get, completions, initialize", () => { + expect(formatResourceReadHuman({ contents: [] })).toBe("(empty resource)"); + expect( + formatResourceReadHuman({ + contents: [ + { uri: "u://a", mimeType: "text/plain", text: "hi" }, + { uri: "u://b", blob: "zzzz" }, + ], + }), + ).toContain("[Blob:"); + + expect(formatPromptResultHuman({})).toBe("(empty prompt)"); + expect( + formatPromptResultHuman({ + description: "desc", + messages: [ + { role: "user", content: "plain" }, + { + role: "assistant", + content: [{ type: "text", text: "block" }], + }, + { role: "user", content: { type: "text", text: "obj" } }, + ], + }), + ).toContain("[assistant]"); + + expect(formatCompletionsHuman({ values: ["a"], hasMore: true })).toContain( + "(more available)", + ); + expect(formatCompletionsHuman({ values: [] })).toContain("(none)"); + + expect( + formatInitializeHuman({ + serverInfo: { name: "s", version: "1" }, + protocolVersion: "2025-01-01", + instructions: " use me ", + capabilities: { tools: {} }, + }), + ).toContain("Capabilities: tools"); + expect(formatInitializeHuman({})).toContain("(unknown)"); + expect( + formatInitializeHuman({ + serverInfo: { name: "s" }, + instructions: " ", + capabilities: {}, + }), + ).toContain("Server: s"); + }); + + it("formats admin and app-info helpers", () => { + expect( + formatAuthListHuman({ + oauthStatePath: "/tmp/oauth.json", + servers: [ + { + url: "https://example.com/mcp", + hasTokens: true, + hasRefreshToken: true, + }, + { url: "https://empty.example/mcp" }, + ], + }), + ).toMatch(/Stored auth[\s\S]*example\.com[\s\S]*tokens[\s\S]*no tokens/); + expect( + formatAuthListHuman({ oauthStatePath: "/tmp/x", servers: [] }), + ).toContain("(none)"); + expect(formatServersListHuman([])).toContain("(none)"); + expect( + formatServersListHuman([{ name: "s", type: "stdio", detail: "x" }]), + ).toContain("`s`"); + expect( + formatServersListHuman([ + { + name: "s", + type: "stdio", + detail: "x", + session: "s", + isMru: true, + }, + ]), + ).toMatch(/@s \(MRU\)/); + expect( + formatServerShowHuman({ + name: "s", + type: "stdio", + detail: "node x", + config: { type: "stdio", command: "node" }, + }), + ).toMatch(/Server[\s\S]*`s`[\s\S]*node x/); + + expect(formatSessionsListHuman([])).toContain("connect first"); + expect( + formatSessionsListHuman([ + { name: "a", isMru: true, serverIdentity: "id" }, + ]), + ).toContain("(MRU)"); + // protocolEra is on every SessionInfo now (#2298 follow-up), not just + // sessions/show — sessions/list renders it inline; its absence (an older + // daemon reply, hypothetically) must not print a bare "[undefined]". + expect( + formatSessionsListHuman([ + { name: "a", isMru: true, serverIdentity: "id", protocolEra: "modern" }, + ]), + ).toContain("— id [modern]"); + expect( + formatSessionsListHuman([ + { name: "a", isMru: false, serverIdentity: "id" }, + ]), + ).not.toContain("["); + expect( + formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), + ).toContain("Session `@a`"); + // sessions/show enrichment: era without a protocolVersion, serverInfo + // without a version, empty capabilities, an empty/non-array + // supportedVersions, and blank instructions each take the "nothing to + // append" branch rather than the populated one exercised elsewhere. + expect( + formatSessionInfoHuman({ + name: "a", + protocolEra: "legacy", + serverInfo: { name: "demo" }, + capabilities: {}, + supportedVersions: [], + instructions: "", + }), + ).toMatch(/Era: legacy\nServer info: demo\nCapabilities: \(none\)/); + expect( + formatSessionInfoHuman({ + name: "a", + protocolEra: undefined, + protocolVersion: "2025-11-25", + serverInfo: { name: "demo", version: "1.2.3" }, + supportedVersions: ["2025-11-25", "2025-06-18"], + instructions: "Say hi.", + }), + ).toMatch( + /Era: unknown \(2025-11-25\)[\s\S]*demo v1\.2\.3[\s\S]*Supported versions: 2025-11-25, 2025-06-18[\s\S]*Instructions: Say hi\./, + ); + + // Auth snapshot line: OAuth with full detail, EMA with IdP session state, + // and a bare not-authorized snapshot (no scope/clientId branches). + expect( + formatSessionInfoHuman({ + name: "a", + auth: { + method: "oauth", + authorized: true, + scope: "mcp:tools", + clientId: "client-123", + }, + }), + ).toContain( + "Auth: OAuth (authorized; scope: mcp:tools; client: client-123)", + ); + expect( + formatSessionInfoHuman({ + name: "a", + auth: { method: "ema", authorized: true, idpSession: "logged_in" }, + }), + ).toContain("Auth: EMA (authorized; IdP session: logged_in)"); + expect( + formatSessionInfoHuman({ + name: "a", + auth: { method: "oauth", authorized: false }, + }), + ).toContain("Auth: OAuth (not authorized)"); + + expect( + formatAppInfoListHuman([ + { toolName: "with", hasApp: true, resourceUri: "ui://x" }, + { toolName: "err", hasApp: false, resourceError: "boom" }, + { toolName: "no", hasApp: false }, + ]), + ).toContain("no app"); + + const verifyText = formatSkillVerifyListHuman([ + { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" }, + { + name: "bad-skill", + uri: "skill://bad/SKILL.md", + outcome: "failed", + conformance: [{ severity: "error" }], + files: [{ status: "mismatch" }], + }, + { + name: "cut-short", + uri: "skill://cut/SKILL.md", + outcome: "incomplete", + incomplete: "read bounds hit", + }, + ]); + expect(verifyText).toContain("Skill verification (3):"); + expect(verifyText).toContain("`ok-skill`"); + expect(verifyText).toContain("verified"); + expect(verifyText).toContain( + "`bad-skill` (skill://bad/SKILL.md) — failed — 1 issue(s), 1 file mismatch(es)", + ); + expect(verifyText).toContain("`cut-short`"); + expect(verifyText).toContain("read bounds hit"); + + expect( + formatAppInfoHuman({ + toolName: "t", + hasApp: true, + resourceUri: "ui://x", + csp: { a: 1 }, + }), + ).toContain("CSP:"); + expect( + formatAppInfoHuman({ toolName: "t", hasApp: false, resourceError: "e" }), + ).toContain("e"); + expect(formatAppInfoHuman({ toolName: "t", hasApp: false })).toContain( + "No MCP App", + ); + }); + + it("formats stream events and rpc dispatch", () => { + expect(formatStreamEventHuman(null)).toBe("null"); + expect(formatStreamEventHuman({ type: "subscribed", uri: "u" })).toBe( + "Subscribed: u", + ); + expect( + formatStreamEventHuman({ type: "resources/updated", uri: "u" }), + ).toBe("Resource updated: u"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { + method: "notifications/message", + params: { level: "warn", logger: "L", data: "hi" }, + }, + }), + ).toBe("[warn] L: hi"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { params: { message: "m" } }, + }), + ).toBe("[info] m"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { params: { nested: true } }, + }), + ).toContain("nested"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: {}, + }), + ).toContain("[info]"); + expect(formatStreamEventHuman({ other: 1 })).toContain('"other": 1'); + expect(formatStreamEventHuman("raw")).toBe("raw"); + + expect(formatRpcResultHuman("tools/list", { tools: [] })).toContain( + "Tools", + ); + expect(formatRpcResultHuman("tools/call", { content: [] })).toBe( + "(no content)", + ); + expect(formatRpcResultHuman("resources/list", { resources: [] })).toContain( + "Resources", + ); + expect(formatRpcResultHuman("resources/read", { contents: [] })).toBe( + "(empty resource)", + ); + expect( + formatRpcResultHuman("resources/templates/list", { + resourceTemplates: [], + }), + ).toContain("templates"); + expect(formatRpcResultHuman("resources/unsubscribe", { uri: "u" })).toBe( + "Unsubscribed: u", + ); + expect(formatRpcResultHuman("prompts/list", { prompts: [] })).toContain( + "Prompts", + ); + expect(formatRpcResultHuman("prompts/get", {})).toBe("(empty prompt)"); + expect(formatRpcResultHuman("prompts/complete", { values: [] })).toContain( + "Completions", + ); + expect( + formatRpcResultHuman("initialize", { serverInfo: { name: "s" } }), + ).toContain("Server: s"); + expect(formatRpcResultHuman("logging/setLevel", {})).toBe( + "Logging level updated.", + ); + expect(formatRpcResultHuman("tasks/list", { tasks: [] })).toContain( + "Tasks", + ); + expect( + formatRpcResultHuman("tasks/get", { task: { taskId: "1", status: "x" } }), + ).toContain("Task: `1`"); + expect(formatRpcResultHuman("tasks/cancel", { taskId: "1" })).toBe( + "Cancelled task: 1", + ); + expect(formatRpcResultHuman("tasks/result", { content: [] })).toBe( + "(no content)", + ); + expect(formatRpcResultHuman("roots/list", { roots: [] })).toContain( + "Roots", + ); + expect(formatRpcResultHuman("roots/set", { roots: [] })).toContain("Roots"); + expect(formatRpcResultHuman("unknown/op", { x: 1 })).toBeNull(); + }); +}); + +describe("writeSessionOutput", () => { + let stdout: string; + let stderr: string; + let original: typeof process.stdout.write; + let originalErr: typeof process.stderr.write; + + beforeEach(() => { + stdout = ""; + stderr = ""; + original = process.stdout.write; + originalErr = process.stderr.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stdout.write = original; + process.stderr.write = originalErr; + }); + + it("pretty-prints json without a result envelope", async () => { + await writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/list", + result: { tools: [] }, + }, + ); + expect(stdout).toBe('{\n "tools": []\n}\n'); + }); + + it("ignores auto-collected appInfo on tools/call json", async () => { + await writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { content: [{ type: "text", text: "ok" }] }, + appInfo: { hasApp: false, toolName: "echo" }, + }, + ); + expect(JSON.parse(stdout)).toEqual({ + content: [{ type: "text", text: "ok" }], + }); + }); + + it("throws NO_APP after printing app-info text", async () => { + await expect( + writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "tools/call", + result: { hasApp: false, toolName: "x" }, + }, + ), + ).rejects.toMatchObject({ exitCode: EXIT_CODES.NO_APP }); + expect(stdout).toContain("has no MCP App"); + }); + + it("allows hasApp true app-info probes", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "tools/call", + result: { hasApp: true, toolName: "x", resourceUri: "ui://x" }, + }, + ); + expect(stdout).toContain("has an MCP App"); + }); + + it("throws TOOL_ERROR when isError", async () => { + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { isError: true, content: [] }, + toolName: "echo", + }, + ), + ).rejects.toBeInstanceOf(CliExitCodeError); + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { isError: true, content: [] }, + }, + ), + ).rejects.toMatchObject({ message: expect.stringContaining("tool") }); + }); + + it("falls back to pretty JSON for unknown rpc methods in text mode", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "custom/x", + result: { ok: 1 }, + }, + ); + expect(stdout).toContain('"ok": 1'); + }); + + it("renders skill-verify NDJSON with its own formatter, not app-info's", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "ndjson", + variant: "skill-verify", + lines: [ + { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" }, + ], + summary: "Verified 1 skill and 0 files: no conformance errors.", + }, + ); + expect(stdout).toContain("Skill verification (1):"); + expect(stdout).not.toContain("App info"); + expect(stderr).toBe( + "Verified 1 skill and 0 files: no conformance errors.\n", + ); + }); + + it("throws with the verify exit code after printing the report and summary", async () => { + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "ndjson", + variant: "skill-verify", + lines: [ + { + name: "bad-skill", + uri: "skill://bad/SKILL.md", + outcome: "failed", + }, + ], + summary: "1 of 1 skill failed verification.", + exitCode: EXIT_CODES.SKILL_NONCONFORMANT, + }, + ), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.SKILL_NONCONFORMANT, + envelope: { code: "skills_nonconformant" }, + }); + // Report already on stdout, summary on stderr — both happen before the throw. + expect(stdout).toContain("bad-skill"); + expect(stderr).toBe("1 of 1 skill failed verification.\n"); + }); + + it("formats every admin/stream payload kind", async () => { + const kinds = [ + { + kind: "ndjson" as const, + lines: [{ toolName: "a", hasApp: false }], + }, + { kind: "stream-event" as const, data: { type: "subscribed", uri: "u" } }, + { + kind: "servers/list" as const, + servers: [{ name: "s", type: "stdio", detail: "d" }], + }, + { + kind: "servers/show" as const, + server: { + name: "s", + type: "stdio", + detail: "d", + config: { type: "stdio", command: "n" }, + }, + }, + { + kind: "sessions/list" as const, + sessions: [{ name: "a", serverIdentity: "id" }], + }, + { + kind: "session" as const, + session: { name: "a", serverIdentity: "id" }, + }, + { kind: "disconnect" as const, name: "a" }, + { + kind: "daemon/status" as const, + status: { pid: 1, socketPath: "/tmp/s", sessions: [] }, + }, + { + kind: "daemon/status" as const, + status: { pid: 2, sessions: "bad" }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: false }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: true }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: false, message: "was idle" }, + }, + { + kind: "auth/list" as const, + list: { + oauthStatePath: "/tmp/oauth.json", + servers: [ + { + url: "https://example.com/mcp", + hasTokens: true, + hasRefreshToken: false, + }, + ], + }, + }, + { + kind: "auth/clear" as const, + result: { all: true, cleared: 1 }, + }, + { + kind: "auth/clear" as const, + result: { all: true, cleared: 2 }, + }, + { + kind: "auth/clear" as const, + result: { url: "https://example.com/mcp" }, + }, + { kind: "generic" as const, data: { x: 1 }, title: "Title" }, + { kind: "generic" as const, data: { y: 2 } }, + ]; + + for (const payload of kinds) { + stdout = ""; + await writeSessionOutput({ format: "text" }, payload); + expect(stdout.length).toBeGreaterThan(0); + stdout = ""; + await writeSessionOutput({ format: "json" }, payload); + expect(() => JSON.parse(stdout)).not.toThrow(); + } + }); + + it("defaults undefined format to text", async () => { + await writeSessionOutput( + {}, + { + kind: "disconnect", + name: "z", + }, + ); + expect(stdout).toContain("Disconnected `@z`"); + }); +}); + +describe("format-human ANSI styling", () => { + it("styles human tool lists and log levels when enabled", () => { + const s = createStyle(true); + const tools = formatToolsHuman( + [ + { + name: "echo", + description: "hi", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + }, + ], + s, + ); + expect(tools).toContain("\u001b[1m"); // bold name + expect(tools).toContain("\u001b[36m"); // cyan params + expect(tools).toContain("\u001b[2m"); // dim description + expect(tools).toContain("echo"); + + const log = formatStreamEventHuman( + { + direction: "notification", + message: { params: { level: "error", data: "boom" } }, + }, + s, + ); + expect(log).toContain("\u001b[31m"); + expect(log).toContain("boom"); + }); +}); diff --git a/clients/mcpi/__tests__/helpers/mcp-runner.ts b/clients/mcpi/__tests__/helpers/mcp-runner.ts new file mode 100644 index 0000000000..341dd37cd7 --- /dev/null +++ b/clients/mcpi/__tests__/helpers/mcp-runner.ts @@ -0,0 +1,88 @@ +import { runMcp as invokeMcp } from "../../src/session/mcp.js"; +import { formatErrorOutput } from "@inspector/cli/error-handler.js"; + +export interface McpResult { + exitCode: number | null; + stdout: string; + stderr: string; + output: string; +} + +export interface McpOptions { + timeout?: number; + env?: Record; +} + +type WriteArgs = [ + chunk: unknown, + encoding?: unknown, + callback?: (() => void) | undefined, +]; + +function captureWrite(append: (text: string) => void) { + return (...args: WriteArgs): boolean => { + const [chunk, encoding, callback] = args; + append(typeof chunk === "string" ? chunk : String(chunk)); + const cb = typeof encoding === "function" ? encoding : callback; + if (typeof cb === "function") cb(); + return true; + }; +} + +/** + * In-process runner for `runMcp` (session CLI), mirroring {@link runCli}. + */ +export async function runMcp( + args: string[], + options: McpOptions = {}, +): Promise { + let stdout = ""; + let stderr = ""; + + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + const envBackup: Record = {}; + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + envBackup[key] = process.env[key]; + process.env[key] = value; + } + } + + process.stdout.write = captureWrite((text) => { + stdout += text; + }) as typeof process.stdout.write; + process.stderr.write = captureWrite((text) => { + stderr += text; + }) as typeof process.stderr.write; + + const argv = ["node", "mcpi", ...args]; + const timeoutMs = options.timeout ?? 15000; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`mcpi command timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + let exitCode = 0; + try { + await Promise.race([invokeMcp(argv), timeout]); + } catch (error) { + const out = formatErrorOutput(error); + exitCode = out.exitCode; + stderr += out.stderr; + } finally { + if (timer) clearTimeout(timer); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + for (const [key, value] of Object.entries(envBackup)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + return { exitCode, stdout, stderr, output: stdout + stderr }; +} diff --git a/clients/mcpi/__tests__/hoist-session.test.ts b/clients/mcpi/__tests__/hoist-session.test.ts new file mode 100644 index 0000000000..19b9b13d1a --- /dev/null +++ b/clients/mcpi/__tests__/hoist-session.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { hoistAtSession } from "../src/session/dispatch.js"; + +describe("hoistAtSession", () => { + it("lifts a leading @name into sessionFromAt", () => { + const { argv, sessionFromAt } = hoistAtSession([ + "node", + "mcpi", + "@alpha", + "tools/list", + "--format", + "json", + ]); + expect(sessionFromAt).toBe("alpha"); + expect(argv).toEqual(["node", "mcpi", "tools/list", "--format", "json"]); + }); + + it("leaves argv unchanged when there is no @name", () => { + const input = ["node", "mcpi", "tools/list"]; + expect(hoistAtSession(input)).toEqual({ argv: input }); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts new file mode 100644 index 0000000000..ac6448e638 --- /dev/null +++ b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +const callDaemon = vi.fn(); +const ensureDaemon = vi.fn(); +const authorizeInFrontend = vi.fn(); + +vi.mock("../src/daemon/index.js", () => ({ + callDaemon: (...args: unknown[]) => callDaemon(...args), + ensureDaemon: (...args: unknown[]) => ensureDaemon(...args), + streamDaemon: vi.fn(), +})); + +vi.mock("../src/session/authorize.js", () => ({ + authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), +})); + +describe("mcp.ts auth / daemon error paths", () => { + let configPath: string | undefined; + let stdout: string; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; + + beforeEach(() => { + stdout = ""; + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + + ensureDaemon.mockReset(); + ensureDaemon.mockResolvedValue({ socketPath: "/tmp/mcp-auth-cov.sock" }); + callDaemon.mockReset(); + authorizeInFrontend.mockReset(); + authorizeInFrontend.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("connect --ema overlays enterpriseManaged onto the resolved settings", async () => { + configPath = createSampleTestConfig(); + callDaemon.mockResolvedValueOnce({ + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + }); + + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--ema", + "--format", + "json", + ]); + + const connectCall = callDaemon.mock.calls.find((c) => c[0] === "connect"); + const params = connectCall?.[1] as { + serverSettings?: { enterpriseManaged?: boolean }; + }; + expect(params.serverSettings?.enterpriseManaged).toBe(true); + }); + + it("retries connect after auth_required via authorizeInFrontend", async () => { + configPath = createSampleTestConfig(); + const session = { + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + }; + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce(session); + + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + expect(authorizeInFrontend).toHaveBeenCalledOnce(); + expect(callDaemon).toHaveBeenCalledTimes(2); + expect(JSON.parse(stdout.trim()).name).toBe("test-stdio"); + }); + + it("re-ensures the daemon after authorizeInFrontend, in case interactive OAuth outlasted its idle timeout", async () => { + configPath = createSampleTestConfig(); + const session = { + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + }; + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce(session); + // Simulate the pre-auth daemon having idled out while OAuth ran: the + // retry's ensureDaemon() call returns a different (freshly respawned) + // socket than the one used for the first attempt. + ensureDaemon + .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-stale.sock" }) + .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-fresh.sock" }); + + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + expect(ensureDaemon).toHaveBeenCalledTimes(2); + expect(callDaemon).toHaveBeenCalledTimes(2); + expect(callDaemon.mock.calls[0][2]).toMatchObject({ + socketPath: "/tmp/mcp-auth-cov-stale.sock", + }); + expect(callDaemon.mock.calls[1][2]).toMatchObject({ + socketPath: "/tmp/mcp-auth-cov-fresh.sock", + }); + }); + + it("rejects --relogin with --stored-auth-only", async () => { + configPath = createSampleTestConfig(); + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp([ + "node", + "mcpi", + "--stored-auth-only", + "connect", + "test-stdio", + "--config", + configPath, + "--relogin", + ]), + ).rejects.toMatchObject({ exitCode: 1 }); + expect(callDaemon).not.toHaveBeenCalled(); + }); + + it("clears stored auth on connect --relogin for HTTP targets", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const { resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-relogin-")); + const oauthFile = path.join(dir, "oauth.json"); + fs.writeFileSync( + oauthFile, + JSON.stringify({ + servers: { + "http://example.com/mcp": { + tokens: { access_token: "x", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + const prev = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = oauthFile; + resetNodeOAuthStorageCache(); + + callDaemon.mockResolvedValueOnce({ + name: "http", + isMru: true, + serverIdentity: "http://example.com/mcp", + }); + + try { + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "--session", + "relogin-http", + "--server-url", + "http://example.com/mcp", + "--transport", + "http", + "--relogin", + "--format", + "json", + ]); + expect(callDaemon).toHaveBeenCalledOnce(); + const after = JSON.parse(fs.readFileSync(oauthFile, "utf8")) as { + servers?: Record; + }; + expect(after.servers?.["http://example.com/mcp"]).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prev; + resetNodeOAuthStorageCache(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rethrows auth_required when --stored-auth-only is set", async () => { + configPath = createSampleTestConfig(); + callDaemon.mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ); + + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--stored-auth-only", + "--format", + "json", + ]), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.AUTH_REQUIRED, + envelope: { code: "auth_required" }, + }); + expect(authorizeInFrontend).not.toHaveBeenCalled(); + }); + + it("rethrows unexpected daemon/stop errors", async () => { + callDaemon.mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.USAGE, "boom", { code: "usage" }), + ); + + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp(["node", "mcpi", "daemon", "stop", "--format", "json"]), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.USAGE, + envelope: { code: "usage" }, + }); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts new file mode 100644 index 0000000000..32c9f87cc3 --- /dev/null +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -0,0 +1,468 @@ +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { callDaemon } from "../src/daemon/client.js"; + +describe("mcp.ts coverage", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + beforeAll(() => { + expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true); + }); + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("covers RPC registrations, metadata parse, and --plain", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + const withMeta = await runMcp( + [ + "tools/list", + "--metadata", + "client=session-cov", + "--metadata", + "count=1", + // Object value must JSON.stringify (not String → "[object Object]"). + "--metadata", + 'nested={"a":1}', + "--plain", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(withMeta); + + const badMeta = await runMcp(["tools/list", "--metadata", "novalue"], { + env: e, + }); + expectCliFailure(badMeta); + + const emptyMeta = await runMcp(["tools/list", "--metadata", "k="], { + env: e, + }); + expectCliFailure(emptyMeta); + + const read = await runMcp( + [ + "resources/read", + "demo://resource/static/document/architecture.md", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(read); + + // Same Commander action as subscribe (uri positional / --uri); prefer + // unsubscribe so we don't open a long-lived stream in this suite. + const unsub = await runMcp( + ["resources/unsubscribe", "test://env", "--format", "json"], + { env: e, timeout: 20000 }, + ); + // Default test server does not advertise subscriptions. + expectCliFailure(unsub); + expect(unsub.stderr).toMatch(/unsubscribe|Method not found/i); + + const prompt = await runMcp( + [ + "prompts/get", + "simple_prompt", + "--prompt-args", + "unused=1", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(prompt); + + const completeBad = await runMcp( + ["prompts/complete", "--complete-ref-type", "nope"], + { env: e }, + ); + expectCliFailure(completeBad); + + const complete = await runMcp( + [ + "prompts/complete", + "--complete-ref-type", + "ref/prompt", + "--complete-ref", + "simple_prompt", + "--complete-arg-name", + "name", + "--complete-arg-value", + "s", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + // Completion support varies; assert the command ran (not a usage parse error). + expect(complete.stderr).not.toMatch(/complete-ref-type/); + expect([0, 1]).toContain(complete.exitCode); + + const logOk = await runMcp( + ["logging/setLevel", "debug", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(logOk); + + const logBad = await runMcp(["logging/setLevel", "--log-level", "nope"], { + env: e, + }); + expectCliFailure(logBad); + + const taskGet = await runMcp( + ["tasks/get", "missing-task", "--format", "json"], + { + env: e, + timeout: 20000, + }, + ); + expectCliFailure(taskGet); + + const taskCancel = await runMcp( + ["tasks/cancel", "--task-id", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskCancel); + + const taskResult = await runMcp( + ["tasks/result", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskResult); + + const taskUpdateNoBody = await runMcp( + ["tasks/update", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdateNoBody); + expect(taskUpdateNoBody.stderr).toMatch(/--input-responses/); + + const taskUpdateBadJson = await runMcp( + [ + "tasks/update", + "missing-task", + "--input-responses", + "not-json", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdateBadJson); + expect(taskUpdateBadJson.stderr).toMatch(/--input-responses is invalid/); + + const taskUpdate = await runMcp( + [ + "tasks/update", + "missing-task", + "--input-responses", + '{"req-1":"answer"}', + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdate); + + const roots = await runMcp( + ["roots/set", "--roots-json", "[]", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(roots); + + const called = await runMcp( + [ + "tools/call", + "--tool-name", + "echo", + "--tool-arg", + "message=cov", + "--tool-metadata", + "src=test", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(called); + + const templates = await runMcp( + ["resources/templates/list", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(templates); + + const prompts = await runMcp(["prompts/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(prompts); + + const tasks = await runMcp(["tasks/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(tasks); + expect(JSON.parse(tasks.stdout)).toHaveProperty("tasks"); + + const rootsList = await runMcp(["roots/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(rootsList); + expect(JSON.parse(rootsList.stdout)).toHaveProperty("roots"); + + const show = await runMcp( + [ + "servers/show", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(show); + + // Skills support is optional; the default test server may not advertise + // it. Either way, the RPC action itself should run (not a usage error). + const skillsList = await runMcp(["skills/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expect([0, 1]).toContain(skillsList.exitCode); + + const skillsListVerify = await runMcp( + ["skills/list", "--verify", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsListVerify.exitCode); + + const skillsGet = await runMcp( + ["skills/get", "test://skill", "--verify", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsGet.exitCode); + + const skillsGetFlagUri = await runMcp( + ["skills/get", "--uri", "test://skill", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsGetFlagUri.exitCode); + + await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { + env: e, + }, + ); + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); + + it("covers ad-hoc connect options and servers/list catalog env", async () => { + configPath = createSampleTestConfig(); + const e = env(); + const { command, args } = getTestMcpServerCommand(); + + const adHoc = await runMcp( + [ + "connect", + "--session", + "opts", + "--transport", + "stdio", + "--cwd", + process.cwd(), + "-e", + "COV_FLAG=1", + "--connect-timeout", + "15000", + "--era", + "auto", + "--elicit", + "url", + "--ema", + "--format", + "json", + command, + ...args, + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(adHoc); + + // Invalid --era is rejected before any connection is attempted. + const badEra = await runMcp( + ["connect", "--era", "bogus", "--format", "json", command, ...args], + { env: e, timeout: 20000 }, + ); + expectCliFailure(badEra); + expect(badEra.stderr).toMatch(/Invalid --era/); + + // Invalid --elicit is rejected before any connection is attempted. + const badElicit = await runMcp( + ["connect", "--elicit", "bogus", "--format", "json", command, ...args], + { env: e, timeout: 20000 }, + ); + expectCliFailure(badElicit); + expect(badElicit.stderr).toMatch(/Invalid --elicit/); + + await runMcp(["disconnect", "--session", "opts", "--format", "json"], { + env: e, + }); + + // Ad-hoc HTTP with --server-url and no positional rest (empty-rest branch). + const urlOnly = await runMcp( + [ + "connect", + "--session", + "urlonly", + "--transport", + "http", + "--server-url", + "http://127.0.0.1:9/mcp", + "--header", + "X-Test: 1", + "--connect-timeout", + "100", + "--format", + "json", + ], + { env: e, timeout: 10000 }, + ); + expectCliFailure(urlOnly); + // Unreachable HTTP should classify as exit 4 when the error is network-shaped. + expect([1, 4]).toContain(urlOnly.exitCode); + + const listed = await runMcp(["servers/list", "--format", "json"], { + env: { + ...e, + MCP_CATALOG_PATH: configPath, + }, + }); + expectCliSuccess(listed); + + // Whitespace --config → trim || undefined branch on servers/list. + const emptyConfig = await runMcp( + ["servers/list", "--config", " ", "--format", "json"], + { env: { ...e, MCP_CATALOG_PATH: configPath } }, + ); + expectCliSuccess(emptyConfig); + + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); + + it("bare mcpi / --help print usage without an ErrorEnvelope", async () => { + // Bare invocation: Commander writes help to stderr (help-after-error). + const bare = await runMcp([]); + expectCliSuccess(bare); + expect(bare.stderr).toMatch(/Usage:/i); + expect(bare.stderr).not.toContain('"error"'); + + const help = await runMcp(["--help"]); + expectCliSuccess(help); + expect(help.stdout).toMatch(/Usage:/i); + expect(help.stderr).not.toContain('"error"'); + }); + + it("covers exitOverride (unknown command) and default process.argv", async () => { + // Non-zero CommanderError goes through exitOverride → throw err. + const unknown = await runMcp(["not-a-command"]); + expectCliFailure(unknown); + + configPath = createSampleTestConfig(); + const originalArgv = process.argv; + process.argv = [ + "node", + "mcpi", + "servers/list", + "--config", + configPath, + "--format", + "json", + ]; + try { + const { runMcp: invoke } = await import("../src/session/mcp.js"); + await invoke(); + } finally { + process.argv = originalArgv; + } + }); + + it("sessions/list and daemon status do not auto-spawn the daemon", async () => { + const e = env(); + const listed = await runMcp(["sessions/list", "--format", "json"], { + env: e, + }); + expectCliSuccess(listed); + expect(JSON.parse(listed.stdout)).toEqual({ sessions: [] }); + + const status = await runMcp(["daemon", "status", "--format", "json"], { + env: e, + }); + expectCliSuccess(status); + expect(JSON.parse(status.stdout)).toMatchObject({ + running: false, + message: "Daemon is not running.", + }); + + // Socket must not have been created by status/list. + expect(fs.existsSync(path.join(storageDir!, "daemon.sock"))).toBe(false); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts new file mode 100644 index 0000000000..e20e192234 --- /dev/null +++ b/clients/mcpi/__tests__/mcp-session.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { runCli } from "../../cli/__tests__/helpers/cli-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; +import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { callDaemon } from "../src/daemon/client.js"; + +describe("mcp session CLI", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + beforeAll(() => { + // Auto-spawn needs the built daemon bundle. + expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true); + }); + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-session-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("lists servers without a daemon", async () => { + configPath = createSampleTestConfig(); + // No MCP_STORAGE_DIR — this path must not touch the daemon. + const result = await runMcp([ + "servers/list", + "--config", + configPath, + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + servers: { name: string }[]; + }; + expect(body.servers.some((s) => s.name === "test-stdio")).toBe(true); + }); + + it("connects, lists sessions, disconnects via auto-spawned daemon", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + const session = JSON.parse(connected.stdout) as { + name: string; + isMru: boolean; + }; + expect(session.name).toBe("test-stdio"); + expect(session.isMru).toBe(true); + + const listed = await runMcp(["sessions/list", "--format", "json"], { + env: e, + }); + expectCliSuccess(listed); + const sessions = JSON.parse(listed.stdout) as { + sessions: { name: string; isMru: boolean }[]; + }; + expect(sessions.sessions).toHaveLength(1); + expect(sessions.sessions[0]?.name).toBe("test-stdio"); + + const servers = await runMcp( + ["servers/list", "--config", configPath, "--format", "json"], + { env: e }, + ); + expectCliSuccess(servers); + const serverBody = JSON.parse(servers.stdout) as { + servers: { + name: string; + session?: string; + isMru?: boolean; + }[]; + }; + const stdio = serverBody.servers.find((s) => s.name === "test-stdio"); + expect(stdio?.session).toBe("test-stdio"); + expect(stdio?.isMru).toBe(true); + expect( + serverBody.servers.find((s) => s.name === "test-http")?.session, + ).toBeUndefined(); + + const disc = await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { env: e }, + ); + expectCliSuccess(disc); + + const stopped = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopped); + }); + + it("one-shot servers/list still works alongside session mode", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/list", + ]); + expectCliSuccess(result); + expect(result.stdout).toContain("test-stdio"); + }); + + it("runs tools/list, tools/call, and sessions/show over a live session", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + const tools = await runMcp(["tools/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(tools); + const toolsBody = JSON.parse(tools.stdout) as { + tools: { name: string }[]; + }; + expect(toolsBody.tools.length).toBeGreaterThan(0); + + const toolsText = await runMcp(["tools/list"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(toolsText); + expect(toolsText.stdout).toMatch(/Tools \(\d+\):/); + expect(toolsText.stdout).toContain("`"); + + const called = await runMcp( + ["tools/call", "echo", "message:=session", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(called); + + const calledJson = await runMcp( + ["tools/call", "echo", '{"message":"session-json"}', "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(calledJson); + + const resources = await runMcp(["resources/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(resources); + + const shown = await runMcp( + ["@test-stdio", "sessions/show", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(shown); + const shownBody = JSON.parse(shown.stdout) as { + name?: string; + serverInfo?: { name?: string }; + protocolVersion?: string; + protocolEra?: string; + }; + expect(shownBody.protocolVersion).toBeTruthy(); + expect(shownBody.protocolEra).toBeTruthy(); + + // `sessions/show ` (positional, no `@name`/--session) exercises + // the opts.session-absent fallback to the command's own argument. + const shownByArg = await runMcp( + ["sessions/show", "test-stdio", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(shownByArg); + + await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { + env: e, + }, + ); + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); +}); diff --git a/clients/mcpi/__tests__/parse-tool-args.test.ts b/clients/mcpi/__tests__/parse-tool-args.test.ts new file mode 100644 index 0000000000..971fa620d0 --- /dev/null +++ b/clients/mcpi/__tests__/parse-tool-args.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + parseToolCallPositionals, + resolveToolCallArgs, +} from "../src/session/parse-tool-args.js"; + +describe("parseToolCallPositionals", () => { + it("parses key:=value with JSON typing", () => { + expect( + parseToolCallPositionals([ + "message:=Foo", + "count:=10", + "enabled:=true", + 'cfg:={"a":1}', + 'id:="012"', + ]), + ).toEqual({ + message: "Foo", + count: 10, + enabled: true, + cfg: { a: 1 }, + id: "012", + }); + }); + + it("parses a single inline JSON object", () => { + expect(parseToolCallPositionals(['{"message":"Foo","count":2}'])).toEqual({ + message: "Foo", + count: 2, + }); + }); + + it("rejects bare values, arrays, and mixed JSON+pairs", () => { + expect(() => parseToolCallPositionals(["foo"])).toThrow(/key:=value/); + expect(() => parseToolCallPositionals(["[1]"])).toThrow(/JSON object/); + expect(() => parseToolCallPositionals(["{not-json"])).toThrow( + /Invalid JSON/, + ); + expect(() => parseToolCallPositionals(['{"a":1}', "b:=2"])).toThrow( + /only one argument/, + ); + expect(() => parseToolCallPositionals([":=x"])).toThrow(/missing key/); + expect(parseToolCallPositionals([])).toEqual({}); + }); +}); + +describe("resolveToolCallArgs", () => { + it("uses positionals as the default style", () => { + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=hi"], + }), + ).toEqual({ toolName: "echo", toolArg: { message: "hi" } }); + }); + + it("treats the name slot as an arg when --tool-name is set", () => { + expect( + resolveToolCallArgs({ + toolNameFlag: "echo", + toolNamePos: "message:=hi", + }), + ).toEqual({ toolName: "echo", toolArg: { message: "hi" } }); + }); + + it("keeps --tool-arg and --tool-args-json as alternatives", () => { + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgFlag: { message: "via-flag" }, + }), + ).toEqual({ toolName: "echo", toolArg: { message: "via-flag" } }); + + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: '{"message":"json"}', + }), + ).toEqual({ toolName: "echo", toolArg: { message: "json" } }); + }); + + it("rejects mixing argument styles", () => { + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=a"], + toolArgFlag: { message: "b" }, + }), + ).toThrow(/one style/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=a"], + toolArgsJson: '{"message":"b"}', + }), + ).toThrow(/one style/); + }); + + it("rejects invalid --tool-args-json", () => { + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "{bad", + }), + ).toThrow(/not valid JSON/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "[]", + }), + ).toThrow(/must be a JSON object/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "null", + }), + ).toThrow(/must be a JSON object/); + }); +}); diff --git a/clients/mcpi/__tests__/session-stored-auth.test.ts b/clients/mcpi/__tests__/session-stored-auth.test.ts new file mode 100644 index 0000000000..11c918d10b --- /dev/null +++ b/clients/mcpi/__tests__/session-stored-auth.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { + clearAllStoredAuth, + clearStoredAuth, + clearStoredAuthForRelogin, + listStoredAuth, + resolveStoredAuthKey, +} from "../src/session/stored-auth.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; + +function writeOAuthFixture(dir: string): string { + const file = path.join(dir, "oauth.json"); + fs.writeFileSync( + file, + JSON.stringify({ + servers: { + "https://example.com/mcp": { + byIssuer: { + "https://as.example/": { + tokens: { + access_token: "a", + token_type: "Bearer", + refresh_token: "r", + }, + }, + }, + activeIssuer: "https://as.example/", + }, + "https://other.example/mcp": { + tokens: { access_token: "x", token_type: "Bearer" }, + }, + "https://empty.example/mcp": { + codeVerifier: "cv", + }, + "https://nullish.example/mcp": null, + "https://stringish.example/mcp": "not-an-object", + "https://issuer-empty.example/mcp": { + byIssuer: { + "https://as.example/": {}, + }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + return file; +} + +describe("session stored-auth helpers", () => { + let dir: string | undefined; + let prevPath: string | undefined; + + afterEach(() => { + if (prevPath === undefined) + delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath; + resetNodeOAuthStorageCache(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + function useFixture(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-")); + const file = writeOAuthFixture(dir); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file; + resetNodeOAuthStorageCache(); + return file; + } + + it("lists byIssuer and legacy tokens", async () => { + const file = useFixture(); + const list = await listStoredAuth(); + expect(list.oauthStatePath).toBe(file); + expect(list.servers.map((s) => s.url)).toEqual([ + "https://empty.example/mcp", + "https://example.com/mcp", + "https://issuer-empty.example/mcp", + "https://nullish.example/mcp", + "https://other.example/mcp", + "https://stringish.example/mcp", + ]); + expect(list.servers.find((s) => s.url.includes("nullish"))).toMatchObject({ + hasTokens: false, + hasRefreshToken: false, + }); + expect(list.servers.find((s) => s.url.includes("stringish"))).toMatchObject( + { hasTokens: false, hasRefreshToken: false }, + ); + expect( + list.servers.find((s) => s.url.includes("issuer-empty")), + ).toMatchObject({ hasTokens: false, hasRefreshToken: false }); + expect( + list.servers.find((s) => s.url.includes("example.com")), + ).toMatchObject({ hasTokens: true, hasRefreshToken: true }); + expect(list.servers.find((s) => s.url.includes("other"))).toMatchObject({ + hasTokens: true, + hasRefreshToken: false, + }); + expect(list.servers.find((s) => s.url.includes("empty"))).toMatchObject({ + hasTokens: false, + hasRefreshToken: false, + }); + }); + + it("clears one key and all keys", async () => { + useFixture(); + const cleared = await clearStoredAuth("https://example.com/mcp"); + expect(cleared.url).toBe("https://example.com/mcp"); + let list = await listStoredAuth(); + expect(list.servers.map((s) => s.url)).not.toContain( + "https://example.com/mcp", + ); + + const all = await clearAllStoredAuth(); + expect(all.cleared).toBe(5); + list = await listStoredAuth(); + expect(list.servers).toEqual([]); + }); + + it("resolveStoredAuthKey rejects unknown non-URL keys", async () => { + useFixture(); + await expect(resolveStoredAuthKey("nope")).rejects.toBeInstanceOf( + CliExitCodeError, + ); + }); + + it("clearStoredAuthForRelogin clears by URL", async () => { + useFixture(); + await clearStoredAuthForRelogin("https://other.example/mcp"); + const list = await listStoredAuth(); + expect(list.servers.map((s) => s.url)).not.toContain( + "https://other.example/mcp", + ); + await clearStoredAuthForRelogin(undefined); + await clearStoredAuthForRelogin(" "); + }); + + it("lists an empty store when the file is missing", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-")); + const missing = path.join(dir, "missing-oauth.json"); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = missing; + resetNodeOAuthStorageCache(); + expect(await listStoredAuth()).toMatchObject({ + oauthStatePath: missing, + servers: [], + }); + }); + + it("resolves keys by normalisation and rejects blanks", async () => { + useFixture(); + await expect(resolveStoredAuthKey(" ")).rejects.toBeInstanceOf( + CliExitCodeError, + ); + await expect(resolveStoredAuthKey("https://Example.COM/mcp")).resolves.toBe( + "https://example.com/mcp", + ); + await expect( + resolveStoredAuthKey("https://brand-new.example/mcp"), + ).resolves.toBe("https://brand-new.example/mcp"); + }); +}); + +describe("mcp auth/list and auth/clear", () => { + let dir: string | undefined; + + afterEach(() => { + resetNodeOAuthStorageCache(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("lists and clears via session commands", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + resetNodeOAuthStorageCache(); + + const listed = await runMcp(["auth/list", "--format", "json"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliSuccess(listed); + const body = JSON.parse(listed.stdout) as { + servers: { url: string }[]; + }; + expect(body.servers.length).toBe(6); + + const cleared = await runMcp( + ["auth/clear", "https://example.com/mcp", "--format", "json"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliSuccess(cleared); + expect(JSON.parse(cleared.stdout)).toEqual({ + url: "https://example.com/mcp", + }); + + const all = await runMcp( + ["auth/clear", "--all", "--yes", "--format", "json"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliSuccess(all); + expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 5 }); + }); + + it("rejects --all without --yes when non-interactive", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + const result = await runMcp(["auth/clear", "--all"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliFailure(result); + expect(result.stderr).toMatch(/--yes/); + }); + + it("rejects auth/clear usage errors", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + const none = await runMcp(["auth/clear"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliFailure(none); + + const both = await runMcp( + ["auth/clear", "https://example.com/mcp", "--all", "--yes"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliFailure(both); + + const human = await runMcp(["auth/list"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliSuccess(human); + expect(human.stdout).toMatch(/Stored auth/); + }); +}); diff --git a/clients/mcpi/eslint.config.js b/clients/mcpi/eslint.config.js new file mode 100644 index 0000000000..1c43ee8fdb --- /dev/null +++ b/clients/mcpi/eslint.config.js @@ -0,0 +1,17 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["build", "coverage"]), + { + files: ["**/*.ts"], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.node, + }, + }, +]); diff --git a/clients/mcpi/package-lock.json b/clients/mcpi/package-lock.json new file mode 100644 index 0000000000..c78f268c9a --- /dev/null +++ b/clients/mcpi/package-lock.json @@ -0,0 +1,3387 @@ +{ + "name": "@modelcontextprotocol/mcpi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@modelcontextprotocol/mcpi", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", + "@napi-rs/keyring": "^1.3.0", + "ajv": "8.18.0", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "8.9.0", + "zod": "4.4.3" + }, + "bin": { + "mcpi": "build/mcp-bin.js" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "tsup": "^8.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/body-parser/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/body-parser/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/connect/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express-serve-static-core/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/express-serve-static-core/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/send/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/send/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/serve-static/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clients/mcpi/package.json b/clients/mcpi/package.json new file mode 100644 index 0000000000..59043726c0 --- /dev/null +++ b/clients/mcpi/package.json @@ -0,0 +1,51 @@ +{ + "name": "@modelcontextprotocol/mcpi", + "private": true, + "description": "Session-oriented MCP Inspector CLI (mcpi) — connect once, run many commands", + "license": "MIT", + "type": "module", + "main": "build/mcp-bin.js", + "bin": { + "mcpi": "./build/mcp-bin.js" + }, + "files": [ + "build", + "README.md" + ], + "scripts": { + "build": "tsup", + "build:dev": "node build/mcp-bin.js daemon stop >/dev/null 2>&1; tsup", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "check": "npm run format:check && npm run lint && npm run typecheck", + "validate": "npm run check && npm run test", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", + "test-servers:build": "tsc -p ../../test-servers --noCheck", + "pretest": "npm run test-servers:build && npm run build", + "lint": "eslint .", + "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", + "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", + "@napi-rs/keyring": "^1.3.0", + "ajv": "8.18.0", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "8.9.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "tsup": "^8.5.0" + }, + "overrides": { + "@types/node": "^24.12.4" + } +} diff --git a/clients/mcpi/src/daemon/auth.ts b/clients/mcpi/src/daemon/auth.ts new file mode 100644 index 0000000000..68996ef556 --- /dev/null +++ b/clients/mcpi/src/daemon/auth.ts @@ -0,0 +1,43 @@ +import { timingSafeEqual } from "node:crypto"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { DAEMON_TOKEN_ENV } from "./paths.js"; + +/** + * Read the IPC token from the environment (parent client or daemon child). + * Empty / unset → shared (unauthenticated) mode. + */ +export function getDaemonTokenFromEnv( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const token = env[DAEMON_TOKEN_ENV]?.trim(); + return token || undefined; +} + +/** Constant-time compare; false if either side is missing or lengths differ. */ +export function tokensEqual( + expected: string | undefined, + provided: string | undefined, +): boolean { + if (expected === undefined || provided === undefined) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(provided, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * When {@link requiredToken} is set, reject requests that omit or mismatch it. + */ +export function assertDaemonToken( + requiredToken: string | undefined, + provided: string | undefined, +): void { + if (requiredToken === undefined) return; + if (!tokensEqual(requiredToken, provided)) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Daemon IPC authentication failed (missing or invalid token).", + { code: "daemon_auth_failed" }, + ); + } +} diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts new file mode 100644 index 0000000000..c83f6e804f --- /dev/null +++ b/clients/mcpi/src/daemon/client.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import * as net from "node:net"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { encodeRequest } from "./framing.js"; +import { getDaemonSocketPath } from "./paths.js"; +import type { + DaemonOp, + DaemonRequest, + DaemonResponse, + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "./protocol.js"; + +export type DaemonClientOptions = { + socketPath?: string; + /** Per-request timeout in ms. */ + timeoutMs?: number; + /** IPC token; defaults to `MCP_INSPECTOR_DAEMON_TOKEN` when set. */ + token?: string; + /** + * Called when the in-flight `rpc` call surfaces a legacy or modern + * non-task MRTR elicitation mid-call (dual-era support, phase 1). Omit to + * auto-answer `{action: "cancel"}` — appropriate for non-interactive + * callers (e.g. `--format json`, non-TTY) that shouldn't hang waiting on a + * human. The connect timeout is cleared once the first such frame arrives, + * so an interactive prompt isn't bounded by the original request timeout. + */ + onElicitation?: ( + frame: ElicitationRequestFrame, + ) => Promise; + /** + * Abort the in-flight request (e.g. on SIGINT/SIGTERM), failing it with a + * clear cancellation error instead of leaving the caller to kill the + * process abruptly mid-call (mid-`tools/call`, mid-elicitation-wait, etc). + */ + signal?: AbortSignal; +}; + +/** + * Short-lived NDJSON client for one request/response against the daemon. + */ +export async function callDaemon( + op: DaemonOp, + params?: DaemonRequest["params"], + options: DaemonClientOptions = {}, +): Promise { + const socketPath = options.socketPath ?? getDaemonSocketPath(); + const timeoutMs = options.timeoutMs ?? 60_000; + const id = randomUUID(); + const token = options.token ?? getDaemonTokenFromEnv(); + const request: DaemonRequest = { id, op, params }; + if (token !== undefined) request.token = token; + + return new Promise((resolve, reject) => { + let settled = false; + let buffer = ""; + let queue: Promise = Promise.resolve(); + // `let` so settle() can clearTimeout before the assignment if connect fails + // synchronously (prefer-const would put `timer` in the TDZ for that race). + let timer: ReturnType | undefined; + const socket = new net.Socket(); + + function settle(fn: () => void) { + /* v8 ignore next -- settle() no-op when already settled (connect/timeout race) */ + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + socket.removeAllListeners(); + socket.on("error", () => {}); + fn(); + } + + function onAbort() { + fail( + new CliExitCodeError(EXIT_CODES.USAGE, `'${op}' cancelled.`, { + code: "cancelled", + }), + ); + } + + function fail(error: unknown) { + settle(() => { + socket.destroy(); + reject(error); + }); + } + + function succeed(value: T) { + settle(() => { + socket.end(); + resolve(value); + }); + } + + function handleLine(line: string): Promise { + const trimmed = line.trim(); + if (!trimmed) return Promise.resolve(); + let parsed: DaemonResponse | ElicitationRequestFrame; + try { + parsed = JSON.parse(trimmed) as + | DaemonResponse + | ElicitationRequestFrame; + } catch (error) { + fail(error); + return Promise.resolve(); + } + if ( + parsed !== null && + typeof parsed === "object" && + "kind" in parsed && + parsed.kind === "elicitation-request" + ) { + return handleElicitationRequest(parsed as ElicitationRequestFrame); + } + handleResponse(parsed as DaemonResponse); + return Promise.resolve(); + } + + async function handleElicitationRequest( + frame: ElicitationRequestFrame, + ): Promise { + if (frame.id !== id) return; + // A human (or a multi-round MRTR exchange) answering this shouldn't be + // bounded by the original fixed request timeout. + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + const answer = options.onElicitation + ? await options.onElicitation(frame) + : ({ + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + } satisfies ElicitationResponseFrame); + if (settled || socket.destroyed) return; + socket.write(JSON.stringify(answer) + "\n"); + } + + function handleResponse(response: DaemonResponse) { + if (response.id !== id && response.id !== "?") { + return; + } + if (!response.ok) { + fail( + new CliExitCodeError( + response.error.exitCode ?? EXIT_CODES.USAGE, + response.error.message, + { code: response.error.code }, + ), + ); + return; + } + succeed(response.result as T); + } + + socket.on("error", (err) => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Cannot reach session daemon at ${socketPath}: ${err.message}`, + { code: "daemon_unreachable" }, + ), + ); + }); + + // Clean FIN with no response must not sit until timeoutMs (mirrors + // streamDaemon's close guard). + socket.on("close", () => { + if (!settled) { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Session daemon closed the connection during '${op}'`, + { code: "daemon_unreachable" }, + ), + ); + } + }); + + timer = setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon request '${op}' timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs); + + options.signal?.addEventListener("abort", onAbort, { once: true }); + + socket.once("connect", () => { + socket.write(encodeRequest(request)); + }); + + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + // Sequential so an awaited onElicitation prompt fully settles (and + // its answer is written) before the next buffered line is handled. + queue = queue + .then(() => handleLine(line)) + .catch((error) => fail(error)); + } + }); + + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/daemon/elicitation-bridge.ts b/clients/mcpi/src/daemon/elicitation-bridge.ts new file mode 100644 index 0000000000..6731bc1b66 --- /dev/null +++ b/clients/mcpi/src/daemon/elicitation-bridge.ts @@ -0,0 +1,100 @@ +/** + * Bridges `InspectorClient`'s `newPendingElicitation` events to a mid-`rpc` + * duplex exchange with the CLI, for legacy and modern non-task MRTR + * elicitations (dual-era support, phase 1). Task-augmented MRTR elicitation + * (SEP-2663 `origin: "task-input-required"`) is out of scope here — those + * calls already return immediately, so they never need this bridge to keep a + * blocking `rpc` call alive; they'll get their own `tasks/get`-driven + * discoverability + answer commands in a follow-up phase. + */ +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js"; +import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; +import type { InspectorClientEventMap } from "@inspector/core/mcp/inspectorClientEventTarget.js"; +import type { ElicitationChannel } from "./ipc-glue.js"; +import type { ElicitationRequestFrame } from "./protocol.js"; + +/** + * Wires `client`'s pending-elicitation events to `channel` for the duration + * of one in-flight call. Returns a cleanup function that must be called + * (typically in a `finally`) once the call settles, so the listener doesn't + * outlive the request. + * + * Core resolves elicitations sequentially — never more than one pending at a + * time (see `inspectorClient.ts`'s `fulfilInputRequests` and + * `requestWithInputRequired`'s retry loop) — but a single call can pause and + * resume through several of these in turn across MRTR rounds. The `queue` + * here is a defensive belt-and-suspenders in case that guarantee ever + * changes; each event is still handled one at a time, in arrival order. + */ +export function wireElicitationBridge( + client: InspectorClient, + channel: ElicitationChannel, + requestId: string, +): () => void { + let queue: Promise = Promise.resolve(); + + const onNewPendingElicitation = ( + event: TypedEventGeneric, + ) => { + const message = event.detail; + if (message.origin === "task-input-required") { + // Task-augmented — the originating call already returned; nothing here + // is awaiting this elicitation, so leave it pending for a future + // tasks/-based command to answer. + return; + } + queue = queue.then(() => handleOne(channel, requestId, message)); + }; + + client.addEventListener("newPendingElicitation", onNewPendingElicitation); + + return () => { + client.removeEventListener( + "newPendingElicitation", + onNewPendingElicitation, + ); + }; +} + +async function handleOne( + channel: ElicitationChannel, + requestId: string, + message: ElicitationCreateMessage, +): Promise { + const params = message.request.params; + const isUrlMode = params != null && "url" in params; + const frame: ElicitationRequestFrame = { + id: requestId, + kind: "elicitation-request", + elicitationId: message.id, + mode: isUrlMode ? "url" : "form", + message: params?.message ?? "", + requestedSchema: isUrlMode + ? undefined + : (params as { requestedSchema?: Record }) + .requestedSchema, + url: isUrlMode ? (params as { url?: string }).url : undefined, + origin: message.origin, + }; + + try { + const answer = await channel.request(frame); + // Defensive: if the answer's elicitationId somehow doesn't match what we + // asked for, proceed with it anyway (single connection, single pending + // exchange at a time — this should never happen in practice) rather than + // hang the call. + await message.respond({ + action: answer.action, + content: answer.content as + | { [x: string]: string | number | boolean | string[] } + | undefined, + }); + } catch { + // Channel failure (e.g. CLI disconnected mid-prompt). `cancel()` settles + // the pending elicitation regardless of origin/mode — some construction + // sites (notably legacy URL-mode's `awaitUrlElicitation`) never wire a + // reject callback, so `reject()` alone would leave the call hanging. + message.cancel(); + } +} diff --git a/clients/mcpi/src/daemon/ensure.ts b/clients/mcpi/src/daemon/ensure.ts new file mode 100644 index 0000000000..69f6b435e3 --- /dev/null +++ b/clients/mcpi/src/daemon/ensure.ts @@ -0,0 +1,147 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { callDaemon } from "./client.js"; +import { + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, + ensureDaemonDir, + getDaemonDir, + getDaemonSocketPath, +} from "./paths.js"; + +const READY_TIMEOUT_MS = 10_000; +const READY_POLL_MS = 50; + +/** + * Resolve the built daemon entry (`build/daemon.js`) next to this package's + * build output. When running from source under vitest, prefer the built file + * if present; otherwise throw a clear error. + */ +export function resolveDaemonScriptPath(): string { + // ensure.ts lives at src/daemon/ensure.ts → ../../build/daemon.js + // In the bundle, import.meta.url is build/daemon-*.js or similar; tsup emits + // ensure into the daemon entry chunk. Prefer an explicit sibling daemon.js. + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(here, "daemon.js"), + path.resolve(here, "../daemon.js"), + path.resolve(here, "../../build/daemon.js"), + path.resolve(here, "../build/daemon.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + /* v8 ignore next 6 -- only when clients/cli/build is missing; pretest always + builds, and fs.existsSync cannot be spied in this ESM package under vitest. */ + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Session daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/mcpi.`, + { code: "daemon_not_built" }, + ); +} + +async function isDaemonReachable(socketPath: string): Promise { + return new Promise((resolve) => { + let settled = false; + const socket = new net.Socket(); + const done = (ok: boolean) => { + /* v8 ignore next -- re-entry when connect and error both fire */ + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.on("error", () => {}); + socket.destroy(); + resolve(ok); + }; + socket.on("error", () => done(false)); + socket.setTimeout(500); + socket.once("connect", () => done(true)); + /* v8 ignore next -- 500ms probe timeout; ensureDaemon usually connects faster */ + socket.once("timeout", () => done(false)); + socket.connect(socketPath); + }); +} + +async function waitForDaemon( + socketPath: string, + token: string | undefined, +): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isDaemonReachable(socketPath)) { + try { + await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + return; + } catch { + // connected but not ready yet + } + } + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + } + /* v8 ignore next 5 -- requires a stuck spawn */ + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Timed out waiting for session daemon at ${socketPath}`, + { code: "daemon_start_timeout" }, + ); +} + +/** + * Ensure a session daemon is running for the current {@link getDaemonDir}. + * Auto-spawns a detached Node process when the socket is not reachable. + * + * When `MCP_INSPECTOR_DAEMON_TOKEN` is set (private mode), the child inherits + * that token and every IPC call must present it. + */ +export async function ensureDaemon(options?: { + dir?: string; + daemonScript?: string; + token?: string; +}): Promise<{ socketPath: string; spawned: boolean }> { + const dir = options?.dir ?? getDaemonDir(); + const token = options?.token ?? getDaemonTokenFromEnv(); + ensureDaemonDir(dir); + const socketPath = getDaemonSocketPath(dir); + + if (await isDaemonReachable(socketPath)) { + try { + await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + return { socketPath, spawned: false }; + } catch { + // stale socket — fall through to spawn + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } + } + } + + const script = options?.daemonScript ?? resolveDaemonScriptPath(); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + // Pin the socket directory explicitly so parent and child agree even when + // MCP_STORAGE_DIR is unset (default ~/.mcp-inspector). + [DAEMON_DIR_ENV]: dir, + }; + if (token !== undefined) { + childEnv[DAEMON_TOKEN_ENV] = token; + } else { + delete childEnv[DAEMON_TOKEN_ENV]; + } + + const child = spawn(process.execPath, [script], { + detached: true, + stdio: "ignore", + env: childEnv, + }); + child.unref(); + + await waitForDaemon(socketPath, token); + return { socketPath, spawned: true }; +} diff --git a/clients/mcpi/src/daemon/framing.ts b/clients/mcpi/src/daemon/framing.ts new file mode 100644 index 0000000000..fb9822f008 --- /dev/null +++ b/clients/mcpi/src/daemon/framing.ts @@ -0,0 +1,28 @@ +import type { DaemonRequest, DaemonResponse } from "./protocol.js"; + +/** + * Parse one NDJSON line into a daemon request. Returns null for blank lines. + */ +export function parseRequestLine(line: string): DaemonRequest | null { + const trimmed = line.trim(); + if (!trimmed) return null; + const value: unknown = JSON.parse(trimmed); + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + typeof (value as DaemonRequest).id !== "string" || + typeof (value as DaemonRequest).op !== "string" + ) { + throw new Error("Invalid daemon request: expected { id, op, params? }"); + } + return value as DaemonRequest; +} + +export function encodeResponse(response: DaemonResponse): string { + return JSON.stringify(response) + "\n"; +} + +export function encodeRequest(request: DaemonRequest): string { + return JSON.stringify(request) + "\n"; +} diff --git a/clients/mcpi/src/daemon/index.ts b/clients/mcpi/src/daemon/index.ts new file mode 100644 index 0000000000..d7526945bb --- /dev/null +++ b/clients/mcpi/src/daemon/index.ts @@ -0,0 +1,36 @@ +export { + assertDaemonToken, + getDaemonTokenFromEnv, + tokensEqual, +} from "./auth.js"; +export { callDaemon } from "./client.js"; +export { streamDaemon } from "./stream-client.js"; +export { ensureDaemon, resolveDaemonScriptPath } from "./ensure.js"; +export { encodeRequest, encodeResponse, parseRequestLine } from "./framing.js"; +export { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, + getInspectorHome, +} from "./paths.js"; +export type { + ConnectParams, + DaemonOp, + DaemonRequest, + DaemonResponse, + DaemonStatus, + RpcParams, + RpcResult, + SessionInfo, + SessionNameParams, +} from "./protocol.js"; +export { DaemonServer } from "./server.js"; +export { + DEFAULT_IDLE_MS, + isSessionAuthRequiredError, + SessionRegistry, +} from "./sessions.js"; diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts new file mode 100644 index 0000000000..92c5bfa3c4 --- /dev/null +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -0,0 +1,203 @@ +/** + * Low-level Unix-socket accept / stale-socket helpers for {@link DaemonServer}. + * + * Outside the per-file coverage gate (see vitest.config.ts); behavior is + * covered by `__tests__/daemon-stream.test.ts`. + */ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { createInterface } from "node:readline"; +import { encodeResponse, parseRequestLine } from "./framing.js"; +import type { + DaemonRequest, + DaemonResponse, + DaemonStreamFrame, + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "./protocol.js"; + +export type StreamStarter = (writeData: (data: unknown) => void) => () => void; + +/** Result of handling one daemon request — optional long-lived stream. */ +export type HandleOutcome = { + response: DaemonResponse; + /** When set, keep the socket open and push stream frames until closed. */ + startStream?: StreamStarter; +}; + +/** + * Bridges a single in-flight `rpc` call to its owning connection so it can + * pause mid-call for a legacy/modern-non-task elicitation, and resume once + * the CLI answers. See `ElicitationRequestFrame`'s doc comment in + * `protocol.ts` for why one exchange (repeatable) is all a single connection + * ever needs. + */ +export type ElicitationChannel = { + request(frame: ElicitationRequestFrame): Promise; +}; + +export type HandleRequest = ( + request: DaemonRequest, + elicitation: ElicitationChannel, +) => Promise; + +/** + * Per-connection {@link ElicitationChannel}. Writes an elicitation-request + * frame straight onto the socket (ahead of the eventual `DaemonResponse`) and + * waits for the next line to answer it; `acceptDaemonConnection`'s line + * handler gives that next line to {@link tryConsumeLine} instead of parsing + * it as a new top-level request. Rejects any pending exchange if the socket + * disconnects, so a dropped client can't hang the daemon-side call forever. + */ +class ConnectionElicitationChannel implements ElicitationChannel { + private pending: { + resolve: (frame: ElicitationResponseFrame) => void; + reject: (error: Error) => void; + } | null = null; + + constructor(private readonly socket: net.Socket) { + const onDisconnect = () => this.rejectPending("Connection closed"); + socket.once("close", onDisconnect); + socket.once("error", onDisconnect); + } + + request(frame: ElicitationRequestFrame): Promise { + if (this.pending) { + return Promise.reject( + new Error("Another elicitation is already pending on this connection"), + ); + } + return new Promise((resolve, reject) => { + this.pending = { resolve, reject }; + if (this.socket.destroyed) { + this.rejectPending("Connection closed"); + return; + } + this.socket.write(JSON.stringify(frame) + "\n"); + }); + } + + /** Returns true if this line was consumed as a pending elicitation answer. */ + tryConsumeLine(line: string): boolean { + if (!this.pending) return false; + let parsed: ElicitationResponseFrame; + try { + parsed = JSON.parse(line); + } catch { + return false; + } + if (!parsed || parsed.kind !== "elicitation-response") return false; + const { resolve } = this.pending; + this.pending = null; + resolve(parsed); + return true; + } + + private rejectPending(message: string): void { + if (!this.pending) return; + const { reject } = this.pending; + this.pending = null; + reject(new Error(message)); + } +} + +export function acceptDaemonConnection( + socket: net.Socket, + handle: HandleRequest, +): void { + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + const elicitationChannel = new ConnectionElicitationChannel(socket); + rl.on("line", (line) => { + void (async () => { + if (elicitationChannel.tryConsumeLine(line)) return; + let request: DaemonRequest; + try { + const parsed = parseRequestLine(line); + if (!parsed) return; + request = parsed; + } catch (error) { + socket.write( + encodeResponse({ + id: "?", + ok: false, + error: { + code: "invalid_request", + message: error instanceof Error ? error.message : String(error), + }, + }), + ); + return; + } + const outcome = await handle(request, elicitationChannel); + if (socket.destroyed) return; + socket.write(encodeResponse(outcome.response)); + + if (!outcome.response.ok || !outcome.startStream) { + return; + } + + const id = request.id; + let stopped = false; + const writeData = (data: unknown) => { + if (stopped || socket.destroyed) return; + const frame: DaemonStreamFrame = { id, stream: "data", data }; + socket.write(JSON.stringify(frame) + "\n"); + }; + const stop = outcome.startStream(writeData); + const cleanup = () => { + if (stopped) return; + stopped = true; + try { + stop(); + } catch { + // ignore unsubscribe errors + } + if (!socket.destroyed) { + const end: DaemonStreamFrame = { id, stream: "end" }; + socket.write(JSON.stringify(end) + "\n"); + socket.end(); + } + }; + socket.once("close", cleanup); + socket.once("error", cleanup); + })(); + }); + socket.on("error", () => { + rl.close(); + }); +} + +export async function removeStaleDaemonSocket( + socketPath: string, +): Promise { + if (!fs.existsSync(socketPath)) return; + const live = await canConnect(socketPath); + if (live) { + throw new Error( + `Daemon already running at ${socketPath}. Use mcpi daemon stop first.`, + ); + } + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } +} + +async function canConnect(socketPath: string): Promise { + return new Promise((resolve) => { + let settled = false; + const socket = new net.Socket(); + const done = (ok: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.on("error", () => {}); + socket.destroy(); + resolve(ok); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/daemon/paths.ts b/clients/mcpi/src/daemon/paths.ts new file mode 100644 index 0000000000..d850b56af3 --- /dev/null +++ b/clients/mcpi/src/daemon/paths.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** Env: directory that owns daemon.sock + daemon.lock. */ +export const DAEMON_DIR_ENV = "MCP_INSPECTOR_DAEMON_DIR"; + +/** + * Env: IPC bearer token for private daemons. When set in the daemon process, + * every request must present the same value. When unset, the daemon is shared + * (same-UID filesystem trust only). + */ +export const DAEMON_TOKEN_ENV = "MCP_INSPECTOR_DAEMON_TOKEN"; + +/** + * Directory that owns the daemon socket + lock. + * Precedence: + * 1. `MCP_INSPECTOR_DAEMON_DIR` — explicit (private mode / auto-spawn parent) + * 2. `MCP_STORAGE_DIR` — CI / parallel isolation (same override as oauth.json) + * 3. `~/.mcp-inspector` + */ +export function getDaemonDir(): string { + const daemonDir = process.env[DAEMON_DIR_ENV]?.trim(); + if (daemonDir) return path.resolve(daemonDir); + const storage = process.env.MCP_STORAGE_DIR?.trim(); + if (storage) return path.resolve(storage); + /* v8 ignore next 2 -- USERPROFILE is the Windows fallback; CI/darwin use HOME. */ + const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); + return path.join(home, ".mcp-inspector"); +} + +/** `~/.mcp-inspector` (or HOME-equivalent), ignoring daemon-dir overrides. */ +export function getInspectorHome(): string { + /* v8 ignore next 2 -- USERPROFILE is the Windows fallback; CI/darwin use HOME. */ + const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); + return path.join(home, ".mcp-inspector"); +} + +/** + * Create a new private daemon directory under `~/.mcp-inspector/private//` + * (mode `0700`). Does not start the daemon. + */ +export function createPrivateDaemonDir(): string { + const id = randomUUID(); + const dir = path.join(getInspectorHome(), "private", id); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dir, 0o700); + } catch { + // best-effort on platforms that ignore mode + } + return dir; +} + +export function getDaemonSocketPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.sock"); +} + +export function getDaemonLockPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.lock"); +} + +/** Ensure the daemon directory exists before binding the socket. */ +export function ensureDaemonDir(dir: string = getDaemonDir()): void { + fs.mkdirSync(dir, { recursive: true }); +} diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts new file mode 100644 index 0000000000..1b9a0fd3c2 --- /dev/null +++ b/clients/mcpi/src/daemon/protocol.ts @@ -0,0 +1,221 @@ +import type { + InspectorServerSettings, + MCPServerConfig, + PendingRequestOrigin, +} from "@inspector/core/mcp/types.js"; +import type { + CliAppInfo, + MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; +import type { + Implementation, + ProtocolEra, + ServerCapabilities, +} from "@modelcontextprotocol/client"; + +/** Operations the session daemon accepts over IPC. */ +export type DaemonOp = + | "ping" + | "connect" + | "disconnect" + | "sessions/list" + | "sessions/use" + | "sessions/show" + | "daemon/status" + | "daemon/stop" + | "rpc" + | "stream"; + +export type ConnectParams = { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + /** Human-readable server identity for `sessions/list`. */ + serverIdentity: string; +}; + +export type SessionNameParams = { + /** Omit to target the MRU session (TTY). */ + name?: string; + /** + * When true (non-TTY / CI), omit is an error — require an explicit session. + * Front-end sets this from `!process.stdin.isTTY` (not stdout — keying off + * stdin lets piping output, e.g. `mcpi tools/list | jq`, still use MRU when + * a human is at the keyboard) unless opted out via + * `MCP_ALLOW_DEFAULT_SESSION=1`. + */ + requireExplicit?: boolean; +}; + +/** Params for `rpc` / `stream` — session targeting plus method args. */ +export type RpcParams = SessionNameParams & + MethodArgs & { + method: string; + }; + +export type DaemonRequest = { + id: string; + op: DaemonOp; + /** + * IPC auth token. Required when the daemon was started with + * `MCP_INSPECTOR_DAEMON_TOKEN` set (private mode); omitted for the shared + * default daemon. + */ + token?: string; + params?: + | ConnectParams + | SessionNameParams + | RpcParams + | Record; +}; + +export type DaemonErrorBody = { + code: string; + message: string; + /** Suggested CLI exit code when applicable. */ + exitCode?: number; +}; + +export type DaemonResponse = + | { id: string; ok: true; result: unknown } + | { id: string; ok: false; error: DaemonErrorBody }; + +/** Frames after the initial ok response on a `stream` connection. */ +export type DaemonStreamFrame = + | { id: string; stream: "data"; data: unknown } + | { id: string; stream: "end" }; + +/** + * One elicitation request/answer exchange, carried mid-`rpc` call when the + * in-flight tool/prompt/resource call surfaces a legacy or modern non-task + * MRTR elicitation (dual-era support, phase 1 — task-augmented MRTR + * elicitation is a separate follow-up, since that call already returns + * immediately and never blocks a `rpc` round-trip in the first place). + * + * Written by the daemon onto the SAME connection as the originating `rpc` + * request, before its `DaemonResponse`; the CLI answers on that same + * connection with an {@link ElicitationResponseFrame}, and the daemon resumes + * the (still in-flight) call. See `ipc-glue.ts`'s `acceptDaemonConnection` for + * why this needs no new channel: each `rpc` request already owns its + * connection exclusively, and core itself never has more than one elicitation + * pending at a time (sequential by design) — though a single call can + * pause/resume through several of these exchanges before its final response. + */ +export type ElicitationRequestFrame = { + id: string; + kind: "elicitation-request"; + /** `ElicitationCreateMessage.id` — echoed back so the answer can be matched. */ + elicitationId: string; + mode: "form" | "url"; + message: string; + /** Form mode only. */ + requestedSchema?: Record; + /** URL mode only. */ + url?: string; + /** Legacy server→client request vs. modern non-task MRTR round. */ + origin: PendingRequestOrigin; +}; + +export type ElicitationResponseFrame = { + id: string; + kind: "elicitation-response"; + elicitationId: string; + action: "accept" | "decline" | "cancel"; + /** Form mode `action: "accept"` only. */ + content?: Record; +}; + +/** + * Slim connect-time snapshot of a session's authorization, projected from the + * core `OAuthConnectionState` (see {@link SessionInfo.auth}). Absent entirely + * for stdio servers and HTTP servers that never engaged OAuth — cleaner than + * reporting "none" for every local server. + */ +export type SessionAuthInfo = { + method: "oauth" | "ema"; + /** Whether tokens for this server are present in storage. */ + authorized: boolean; + /** Granted scope (from the token response), when known. */ + scope?: string; + /** OAuth client id used with the authorization server, when known. */ + clientId?: string; + /** EMA only: IdP session state at connect time. */ + idpSession?: "none" | "logged_in" | "expired"; +}; + +export type SessionInfo = { + name: string; + serverIdentity: string; + connectedAt: number; + lastAccessedAt: number; + isMru: boolean; + /** + * Negotiated era for this session's connection — legacy `initialize` vs. + * modern `server/discover` (#2298 follow-up). Present everywhere a live + * session is reported (`connect`, `sessions/list`, `sessions/use`), not + * just `sessions/show`, so a user with several open sessions can see which + * era each negotiated without querying them one at a time. Absent only if + * the client hasn't connected (never observed in practice — every code + * path constructing a `SessionInfo` does so from an already-connected + * session). + */ + protocolEra?: ProtocolEra; + /** + * Authorization snapshot. Like `protocolEra`, present everywhere a live + * session is reported so both humans and agents can see *how* a session is + * authenticated (OAuth vs. EMA, authorized or not) without a separate + * query. Freshness varies by op: `connect` computes it right after the + * connection succeeds; `sessions/list` and `sessions/use` reuse that + * connect-time value; `sessions/show` recomputes it live *from disk* so it + * reflects the current persisted state (e.g. after `auth/clear` or + * `auth/ema-logout`, even from another process). Note a live session may + * keep working on its in-memory tokens after storage was cleared — `show` + * reports the persisted state, matching `auth/ema-status`. + */ + auth?: SessionAuthInfo; +}; + +/** + * `sessions/show` result: daemon bookkeeping ({@link SessionInfo}, which as of + * #2298 already carries `protocolEra`) plus the live MCP connection state — + * era-agnostic (`serverInfo`/`capabilities`/`instructions`/`protocolVersion` + * are populated the same way whether they came from a legacy `initialize` + * response or a modern `server/discover`) and era-specific (`supportedVersions`, + * only set when the connect actually probed `server/discover`, i.e. + * `auto`/`modern`). + */ +export type SessionShowResult = SessionInfo & { + serverInfo?: Implementation; + protocolVersion?: string; + capabilities?: ServerCapabilities; + instructions?: string; + supportedVersions?: string[]; +}; + +export type DaemonStatus = { + pid: number; + socketPath: string; + sessions: SessionInfo[]; + idleMs: number | null; +}; + +/** Serializable RPC outcome (no live stream callbacks). */ +export type RpcResult = + | { + kind: "result"; + result: Record; + appInfo?: CliAppInfo; + } + | { + kind: "ndjson"; + lines: unknown[]; + /** + * `skills/list --verify` / `skills/get --verify` one-line stderr + * verdict (#2248). Carried across the daemon socket so the session CLI + * can report the same summary the one-shot CLI does, rather than + * silently dropping it the way an earlier pass through this file did. + */ + summary?: string; + /** Non-zero when the emitted report is itself a failure (`--verify`). */ + exitCode?: number; + }; diff --git a/clients/mcpi/src/daemon/run.ts b/clients/mcpi/src/daemon/run.ts new file mode 100644 index 0000000000..4b28cd1b24 --- /dev/null +++ b/clients/mcpi/src/daemon/run.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Session daemon entrypoint. Spawned detached by {@link ensureDaemon}. + * Optional foreground `mcpi daemon run` is not shipped yet (see v2_cli_v2.md). + */ +import { DaemonServer } from "./server.js"; + +async function main(): Promise { + const server = new DaemonServer({ + onShutdown: () => { + // Allow natural exit once the server closes and idle work finishes. + process.exitCode = 0; + }, + }); + + const shutdown = () => { + void server.stop("signal").then(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + await server.start(); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`mcpi daemon: ${message}\n`); + process.exit(1); +}); diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts new file mode 100644 index 0000000000..de03d87fa8 --- /dev/null +++ b/clients/mcpi/src/daemon/server.ts @@ -0,0 +1,426 @@ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { + classifyError, + CliExitCodeError, + EXIT_CODES, +} from "@inspector/cli/error-handler.js"; +import { runMethod } from "@inspector/cli/handlers/run-method.js"; +import type { MethodArgs } from "@inspector/cli/handlers/method-types.js"; +import { + acceptDaemonConnection, + removeStaleDaemonSocket, + type ElicitationChannel, + type HandleOutcome, +} from "./ipc-glue.js"; +import { wireElicitationBridge } from "./elicitation-bridge.js"; +import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; +import { + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, +} from "./paths.js"; +import type { + ConnectParams, + DaemonRequest, + DaemonResponse, + DaemonStatus, + RpcParams, + RpcResult, + SessionNameParams, + SessionShowResult, +} from "./protocol.js"; +import { + DEFAULT_IDLE_MS, + getLiveSessionAuthInfo, + SessionRegistry, +} from "./sessions.js"; + +/** + * Default channel used when a caller doesn't wire a real one (in-process + * `handle`/`handleOutcome` test call sites that predate elicitation support). + * Immediately cancels any elicitation, matching `elicit: false` behavior — + * these callers never advertise elicitation support to the server anyway. + */ +const autoCancelElicitationChannel: ElicitationChannel = { + request(frame) { + return Promise.resolve({ + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + }); + }, +}; + +export type DaemonServerOptions = { + dir?: string; + idleMs?: number; + /** + * When set, every IPC request must present this token. Defaults to + * `MCP_INSPECTOR_DAEMON_TOKEN` from the environment (private mode). + */ + requiredToken?: string; + /** Called when the daemon should exit (idle timeout or daemon/stop). */ + onShutdown?: () => void; +}; + +/** + * Unix-socket NDJSON daemon that owns {@link SessionRegistry}. + */ +export class DaemonServer { + readonly registry: SessionRegistry; + readonly socketPath: string; + readonly lockPath: string; + readonly dir: string; + private readonly requiredToken: string | undefined; + private server: net.Server | null = null; + private readonly onShutdown: (() => void) | null; + private stopping = false; + + constructor(options: DaemonServerOptions = {}) { + this.dir = options.dir ?? getDaemonDir(); + this.socketPath = getDaemonSocketPath(this.dir); + this.lockPath = getDaemonLockPath(this.dir); + this.requiredToken = options.requiredToken ?? getDaemonTokenFromEnv(); + this.registry = new SessionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); + this.onShutdown = options.onShutdown ?? null; + this.registry.setIdleHandler(() => { + void this.stop("idle"); + }); + } + + async start(): Promise { + ensureDaemonDir(this.dir); + await removeStaleDaemonSocket(this.socketPath); + this.writeLock(); + + this.server = net.createServer((socket) => { + acceptDaemonConnection(socket, (req, elicitation) => + this.handleOutcome(req, elicitation), + ); + }); + + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(this.socketPath, () => { + this.server!.off("error", reject); + resolve(); + }); + }); + + // Restrict socket + lock to the creating user. Private mode also requires + // an IPC token (see specification/v2_cli_v2.md §5.3). + try { + fs.chmodSync(this.socketPath, 0o600); + fs.chmodSync(this.lockPath, 0o600); + } catch { + // Unsupported on some platforms (e.g. Windows named pipes). + } + + // Session-less spawn (e.g. ensureDaemon from tools/list with no sessions) + // must still self-reap — idle was previously only armed after disconnect. + this.registry.armIdleTimerIfEmpty(); + } + + async stop(reason: "idle" | "stop" | "signal" = "stop"): Promise { + void reason; + if (this.stopping) return; + this.stopping = true; + await this.registry.disconnectAll(); + await new Promise((resolve) => { + if (!this.server) { + resolve(); + return; + } + this.server.close(() => resolve()); + }); + this.server = null; + this.removeLockAndSocket(); + this.onShutdown?.(); + } + + status(): DaemonStatus { + return { + pid: process.pid, + socketPath: this.socketPath, + sessions: this.registry.list(), + idleMs: this.registry.idleRemainingMs(), + }; + } + + /** Handle one request; returns the response body (used by in-process tests). */ + async handle( + request: DaemonRequest, + elicitation: ElicitationChannel = autoCancelElicitationChannel, + ): Promise { + return (await this.handleOutcome(request, elicitation)).response; + } + + /** Full handle including optional stream starter (socket accept path). */ + async handleOutcome( + request: DaemonRequest, + elicitation: ElicitationChannel = autoCancelElicitationChannel, + ): Promise { + try { + assertDaemonToken(this.requiredToken, request.token); + return await this.dispatch(request, elicitation); + } catch (error) { + if (error instanceof CliExitCodeError) { + return { + response: { + id: request.id, + ok: false, + error: { + code: error.envelope?.code ?? "cli_error", + message: error.message, + exitCode: error.exitCode, + }, + }, + }; + } + // Match one-shot CLI exit codes (e.g. unreachable → 4, not always 1). + const { exitCode, envelope } = classifyError(error); + return { + response: { + id: request.id, + ok: false, + error: { + code: envelope.code, + message: envelope.message, + exitCode, + }, + }, + }; + } + } + + private async dispatch( + request: DaemonRequest, + elicitation: ElicitationChannel, + ): Promise { + switch (request.op) { + case "ping": + return { + response: { + id: request.id, + ok: true, + result: { pong: true, pid: process.pid }, + }, + }; + case "connect": { + const params = request.params as ConnectParams; + if (!params?.name || !params.serverConfig || !params.serverIdentity) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "connect requires name, serverConfig, and serverIdentity", + { code: "invalid_params" }, + ); + } + return { + response: { + id: request.id, + ok: true, + result: await this.registry.connect(params), + }, + }; + } + case "disconnect": { + const params = (request.params ?? {}) as SessionNameParams; + return { + response: { + id: request.id, + ok: true, + result: await this.registry.disconnect( + params.name, + params.requireExplicit, + ), + }, + }; + } + case "sessions/list": + return { + response: { + id: request.id, + ok: true, + result: { sessions: this.registry.list() }, + }, + }; + case "sessions/use": { + const params = (request.params ?? {}) as SessionNameParams; + if (!params.name) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "sessions/use requires a session name", + { code: "invalid_params" }, + ); + } + return { + response: { + id: request.id, + ok: true, + result: this.registry.use(params.name), + }, + }; + } + case "sessions/show": { + const params = (request.params ?? {}) as SessionNameParams; + const session = this.registry.sessionFor( + params.name, + params.requireExplicit, + ); + const client = session.client; + // Recomputed live from disk (not the connect-time cache and not the + // client's memory-cached storage): `show` reports the *current* + // persisted auth state, so an auth/clear, auth/ema-logout, or a + // web-client re-auth since connect is reflected here. + const auth = await getLiveSessionAuthInfo(session); + const result: SessionShowResult = { + name: session.name, + serverIdentity: session.serverIdentity, + connectedAt: session.connectedAt, + lastAccessedAt: session.lastAccessedAt, + isMru: true, + serverInfo: client.getServerInfo(), + protocolVersion: client.getProtocolVersion(), + protocolEra: client.getProtocolEra(), + ...(auth && { auth }), + capabilities: client.getCapabilities(), + instructions: client.getInstructions(), + supportedVersions: client.getDiscoverResult()?.supportedVersions, + }; + return { + response: { id: request.id, ok: true, result }, + }; + } + case "daemon/status": + return { + response: { id: request.id, ok: true, result: this.status() }, + }; + case "daemon/stop": + queueMicrotask(() => { + void this.stop("stop"); + }); + return { + response: { id: request.id, ok: true, result: { stopping: true } }, + }; + case "rpc": + return { + response: { + id: request.id, + ok: true, + result: await this.runRpc( + request.id, + request.params as RpcParams, + elicitation, + ), + }, + }; + case "stream": + return this.openStream(request.id, request.params as RpcParams); + default: + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Unknown daemon op: ${(request as DaemonRequest).op}`, + { code: "unknown_op" }, + ); + } + } + + private async runRpc( + requestId: string, + params: RpcParams, + elicitation: ElicitationChannel, + ): Promise { + if (!params?.method) { + throw new CliExitCodeError(EXIT_CODES.USAGE, "rpc requires a method", { + code: "invalid_params", + }); + } + const client = this.registry.clientFor(params.name, params.requireExplicit); + const methodArgs = stripSessionFields(params); + const unwire = wireElicitationBridge(client, elicitation, requestId); + let outcome; + try { + outcome = await runMethod(client, methodArgs); + } finally { + unwire(); + } + if (outcome.kind === "stream") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Method '${params.method}' is a stream; use the stream op.`, + { code: "use_stream_op" }, + ); + } + if (outcome.kind === "ndjson") { + return { + kind: "ndjson", + lines: outcome.lines, + summary: outcome.summary, + exitCode: outcome.exitCode, + }; + } + return { + kind: "result", + result: outcome.result, + appInfo: outcome.appInfo, + }; + } + + private async openStream( + id: string, + params: RpcParams, + ): Promise { + if (!params?.method) { + throw new CliExitCodeError(EXIT_CODES.USAGE, "stream requires a method", { + code: "invalid_params", + }); + } + const client = this.registry.clientFor(params.name, params.requireExplicit); + const methodArgs = stripSessionFields(params); + const outcome = await runMethod(client, methodArgs); + if (outcome.kind !== "stream") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Method '${params.method}' is not a stream; use the rpc op.`, + { code: "use_rpc_op" }, + ); + } + return { + response: { + id, + ok: true, + result: { streaming: true, label: outcome.label }, + }, + startStream: outcome.start, + }; + } + + private writeLock(): void { + fs.writeFileSync(this.lockPath, `${process.pid}\n`, { flag: "w" }); + } + + private removeLockAndSocket(): void { + try { + fs.unlinkSync(this.socketPath); + } catch { + // absent is fine + } + try { + fs.unlinkSync(this.lockPath); + } catch { + // absent is fine + } + } +} + +function stripSessionFields( + params: RpcParams, +): MethodArgs & { method: string } { + const { name, requireExplicit, method, ...rest } = params; + void name; + void requireExplicit; + return { method, ...rest }; +} diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts new file mode 100644 index 0000000000..566c3cb9b3 --- /dev/null +++ b/clients/mcpi/src/daemon/sessions.ts @@ -0,0 +1,517 @@ +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { InspectorClientEnvironment } from "@inspector/core/mcp/types.js"; +import { + DEFAULT_ELICIT_CAPABILITY, + eraToVersionNegotiation, + type ElicitCapabilityMode, + type InspectorClientOptions, + type InspectorServerSettings, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + buildOAuthConnectionState, + ConsoleNavigation, + hasPersistedOAuthServerState, + isServerOAuthConfigured, + MutableRedirectUrlProvider, + protocolFromOAuthConfig, +} from "@inspector/core/auth/index.js"; +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, +} from "@inspector/core/client/runner.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { + AuthRecoveryRequiredError, + isUnauthorizedError, +} from "@inspector/core/auth/index.js"; +import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import type { SessionAuthInfo, SessionInfo } from "./protocol.js"; + +const SESSION_CLIENT_NAME = "inspector-cli"; + +/** Default idle timeout after the last session disconnects (~60s). */ +export const DEFAULT_IDLE_MS = 60_000; + +type LiveSession = { + name: string; + serverIdentity: string; + connectedAt: number; + lastAccessedAt: number; + client: InspectorClient; + /** Retained for `sessions/show`'s live auth recompute. */ + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + /** Connect-time snapshot (see {@link SessionInfo.auth}). */ + auth?: SessionAuthInfo; +}; + +/** + * In-memory registry of live MCP sessions owned by the daemon. + */ +export class SessionRegistry { + private readonly sessions = new Map(); + private mruName: string | null = null; + private idleTimer: ReturnType | null = null; + /** Absolute deadline for idle shutdown while the timer is armed. */ + private idleDeadline: number | null = null; + private onIdle: (() => void) | null = null; + private readonly idleMs: number; + + constructor(idleMs: number = DEFAULT_IDLE_MS) { + this.idleMs = idleMs; + } + + /** Register a callback invoked when the idle timer fires with no sessions. */ + setIdleHandler(handler: (() => void) | null): void { + this.onIdle = handler; + } + + /** + * Arm the idle shutdown timer when there are no sessions. + * Called at daemon start so a spawn that never connects still self-reaps, + * and after a failed connect that left the registry empty. + */ + armIdleTimerIfEmpty(): void { + if (this.sessions.size === 0) { + this.armIdleTimer(); + } + } + + list(): SessionInfo[] { + return [...this.sessions.values()] + .map((s) => ({ + name: s.name, + serverIdentity: s.serverIdentity, + connectedAt: s.connectedAt, + lastAccessedAt: s.lastAccessedAt, + isMru: s.name === this.mruName, + protocolEra: s.client.getProtocolEra(), + ...(s.auth && { auth: s.auth }), + })) + .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); + } + + getMruName(): string | null { + return this.mruName; + } + + sessionCount(): number { + return this.sessions.size; + } + + /** + * Resolve a session by explicit name or MRU. Throws {@link CliExitCodeError} + * when missing / ambiguous under CI rules. + */ + resolve( + name: string | undefined, + requireExplicit: boolean | undefined, + ): LiveSession { + if (!name) { + if (requireExplicit) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Explicit --session / @name is required in non-interactive mode.", + { code: "session_required" }, + ); + } + if (!this.mruName) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "No open sessions. Connect first (e.g. mcpi servers/list, mcpi connect ).", + { code: "no_session" }, + ); + } + name = this.mruName; + } + const session = this.sessions.get(name); + if (!session) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Session '${name}' not found. Use mcpi sessions/list.`, + { code: "session_not_found" }, + ); + } + return session; + } + + touch(name: string): void { + const session = this.sessions.get(name); + if (!session) return; + session.lastAccessedAt = Date.now(); + this.mruName = name; + this.clearIdleTimer(); + } + + /** + * Resolve a session for an RPC/stream/show, touch MRU, and return the + * live session (name/serverIdentity/timestamps plus the client). + */ + sessionFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): LiveSession { + const session = this.resolve(name, requireExplicit); + this.touch(session.name); + return session; + } + + /** + * Resolve a session for an RPC/stream, touch MRU, and return its client. + */ + clientFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): InspectorClient { + return this.sessionFor(name, requireExplicit).client; + } + + use(name: string): SessionInfo { + const session = this.resolve(name, true); + this.touch(session.name); + return { + name: session.name, + serverIdentity: session.serverIdentity, + connectedAt: session.connectedAt, + lastAccessedAt: session.lastAccessedAt, + isMru: true, + protocolEra: session.client.getProtocolEra(), + ...(session.auth && { auth: session.auth }), + }; + } + + async connect(params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; + }): Promise { + this.clearIdleTimer(); + + try { + if (this.sessions.has(params.name)) { + // Reconnect: tear down the previous client first. + await this.disconnect(params.name, false); + } + + // Front-end authorize / auth/clear write oauth.json in another process. + // Drop the daemon's cached store so this connect re-reads disk. + resetNodeOAuthStorageCache(); + + const client = await createSessionClient( + params.serverConfig, + params.serverSettings, + ); + + try { + await client.connect(); + } catch (error) { + await safeDisconnect(client); + if (isSessionAuthRequiredError(error)) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + error instanceof Error ? error.message : String(error), + { code: "auth_required" }, + ); + } + throw error; + } + + const now = Date.now(); + const auth = await getSessionAuthInfo(client); + this.sessions.set(params.name, { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + client, + serverConfig: params.serverConfig, + ...(params.serverSettings && { serverSettings: params.serverSettings }), + ...(auth && { auth }), + }); + this.mruName = params.name; + + return { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + isMru: true, + protocolEra: client.getProtocolEra(), + ...(auth && { auth }), + }; + } catch (error) { + // Any failure after clearIdleTimer (createSessionClient, reconnect + // disconnect, client.connect, …) must re-arm so a session-less daemon + // still self-reaps. + this.armIdleTimerIfEmpty(); + throw error; + } + } + + async disconnect( + name: string | undefined, + requireExplicit: boolean | undefined, + ): Promise<{ name: string }> { + const session = this.resolve(name, requireExplicit); + const sessionName = session.name; + this.sessions.delete(sessionName); + if (this.mruName === sessionName) { + // Promote the next most-recently-accessed session, if any. + const remaining = [...this.sessions.values()].sort( + (a, b) => b.lastAccessedAt - a.lastAccessedAt, + ); + this.mruName = remaining[0]?.name ?? null; + } + await safeDisconnect(session.client); + if (this.sessions.size === 0) { + this.armIdleTimer(); + } + return { name: sessionName }; + } + + async disconnectAll(): Promise { + const names = [...this.sessions.keys()]; + for (const name of names) { + await this.disconnect(name, false); + } + this.clearIdleTimer(); + } + + private armIdleTimer(): void { + this.clearIdleTimer(); + if (this.idleMs <= 0 || !this.onIdle) return; + this.idleDeadline = Date.now() + this.idleMs; + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + this.idleDeadline = null; + if (this.sessions.size === 0) { + this.onIdle?.(); + } + }, this.idleMs); + // Don't keep the process alive solely for the idle timer when nothing else + // is pending — the socket server keeps the event loop alive. + this.idleTimer.unref?.(); + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + this.idleDeadline = null; + } + + /** Remaining ms until idle shutdown, or null if not armed. */ + idleRemainingMs(): number | null { + if (this.idleDeadline === null) return null; + return Math.max(0, this.idleDeadline - Date.now()); + } +} + +/** + * Connect failures that should trigger front-end interactive OAuth (then retry), + * not a hard ErrorEnvelope. Includes SDK token-exchange mistakes that happen when + * stored creds need a full re-auth. + */ +export function isSessionAuthRequiredError(error: unknown): boolean { + if ( + error instanceof AuthRecoveryRequiredError || + isUnauthorizedError(error) + ) { + return true; + } + // EMA misconfiguration (no/disabled install-level IdP) must surface via the + // front-end too: authorizeInFrontend re-hits it in-process and maps it to + // actionable mcpi guidance, instead of this daemon relaying the web-centric + // core message in an opaque error envelope. + if (isEmaClientNotConfiguredError(error)) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return ( + /prepareTokenRequest\(\) or authorizationCode is required/i.test(message) || + /redirectUrl is required for authorization_code/i.test(message) || + /No code verifier saved for session/i.test(message) + ); +} + +/** + * Maps a persisted/overridden `elicitCapability` mode onto the `elicit` shape + * `InspectorClient` expects. Absence reads back as {@link + * DEFAULT_ELICIT_CAPABILITY} (`"both"`), matching the pre-#1783 hardcoded + * default so existing sessions keep behaving the same until a caller opts + * into something narrower via `--elicit` or a catalog entry's + * `elicitCapability` field. + */ +export function elicitCapabilityToClientOption( + mode: ElicitCapabilityMode | undefined, +): InspectorClientOptions["elicit"] { + switch (mode ?? DEFAULT_ELICIT_CAPABILITY) { + case "off": + return false; + case "url": + return { url: true }; + case "form": + return { form: true }; + case "both": + return { url: true, form: true }; + } +} + +/** + * Project the core `OAuthConnectionState` down to the slim + * {@link SessionAuthInfo} reported on `SessionInfo`. + */ +function projectAuthState(state: OAuthConnectionState): SessionAuthInfo { + return { + method: state.protocol === "ema" ? "ema" : "oauth", + authorized: state.authorized, + ...(state.grantedScope && { scope: state.grantedScope }), + ...(state.client?.clientId && { clientId: state.client.clientId }), + ...(state.ema?.idpSession && { idpSession: state.ema.idpSession }), + }; +} + +/** + * Connect-time auth snapshot, read through the live client's own storage. + * Undefined for stdio servers and HTTP servers that never engaged OAuth + * (`getOAuthState()` returns undefined for both), so no-auth sessions simply + * omit the field. Best-effort: a storage read failure must never fail the + * connect that already succeeded. + */ +export async function getSessionAuthInfo( + client: InspectorClient, +): Promise { + let state; + try { + state = await client.getOAuthState(); + } catch { + return undefined; + } + if (!state) return undefined; + return projectAuthState(state); +} + +/** + * Live auth snapshot for `sessions/show`, read from *disk* rather than the + * client's storage. `NodeOAuthStorage` is load-once/memory-authoritative, so + * the live client never observes cross-process changes to `oauth.json` (an + * `auth/clear`, `auth/ema-logout`, or a web-client re-auth) — a fresh storage + * after a cache reset does. Mirrors `OAuthManager.getOAuthState()`'s inputs: + * the oauth config assembled from client.json + the saved server settings. + * Best-effort: any failure falls back to the connect-time snapshot's absence + * semantics (undefined). + */ +export async function getLiveSessionAuthInfo(session: { + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; +}): Promise { + try { + const config = session.serverConfig; + if (!isOAuthCapableServerConfig(config)) return undefined; + const serverUrl = "url" in config ? config.url : undefined; + if (typeof serverUrl !== "string" || serverUrl === "") return undefined; + resetNodeOAuthStorageCache(); + const storage = new NodeOAuthStorage(); + const clientConfig = await loadRunnerClientConfig({}); + const authOptions = buildRunnerClientAuthOptions( + clientConfig, + session.serverSettings, + {}, + ); + const oauthConfig = authOptions.oauth ?? {}; + if ( + !isServerOAuthConfigured(oauthConfig) && + !(await hasPersistedOAuthServerState(storage, serverUrl)) + ) { + return undefined; + } + return projectAuthState( + await buildOAuthConnectionState({ + serverUrl, + protocol: protocolFromOAuthConfig(oauthConfig), + configuredScope: oauthConfig.scope, + enterpriseManagedAuth: authOptions.enterpriseManagedAuth, + storage, + }), + ); + } catch { + return undefined; + } +} + +async function createSessionClient( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, +): Promise { + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + if (isOAuthCapableServerConfig(serverConfig)) { + // Must be non-empty: SDK treats a falsy redirectUrl as "non-interactive" and + // calls fetchToken() without an authorization code (breaking stored-token / + // refresh reconnect). Interactive login still runs in the front-end on + // auth_required; this value only keeps the daemon's silent path correct. + const callbackUrlConfig = parseRunnerOAuthCallbackUrl( + process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: new ConsoleNavigation(), + redirectUrlProvider, + }; + } + + const clientConfig = await loadRunnerClientConfig({}); + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + {}, + ); + + return new InspectorClient(serverConfig, { + environment, + clientIdentity: { + name: SESSION_CLIENT_NAME, + version: readInspectorVersion(import.meta.url), + }, + initialLoggingLevel: "debug", + progress: false, + sample: false, + // Elicitation capability advertised to the server: derived from + // `serverSettings.elicitCapability` (settable via a catalog entry or the + // `--elicit` connect flag), defaulting to url+form when unset. A server + // that ignores our (possibly empty) capabilities and elicits anyway is + // defensively auto-declined by the daemon's elicitation prompt. + elicit: elicitCapabilityToClientOption(serverSettings?.elicitCapability), + serverSettings, + ...(serverSettings?.protocolEra && { + versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), + }), + ...clientAuthOptions, + }); +} + +async function safeDisconnect(client: InspectorClient): Promise { + try { + await client.disconnect(); + } catch { + // Best-effort teardown. + } +} diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts new file mode 100644 index 0000000000..7a2419cd37 --- /dev/null +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -0,0 +1,183 @@ +/** + * Long-lived daemon stream client. + * + * Outside the per-file coverage gate (see vitest.config.ts); behavior is + * covered by `__tests__/daemon-stream.test.ts`. + */ +import { randomUUID } from "node:crypto"; +import * as net from "node:net"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { encodeRequest } from "./framing.js"; +import { getDaemonSocketPath } from "./paths.js"; +import type { + DaemonRequest, + DaemonResponse, + DaemonStreamFrame, +} from "./protocol.js"; +import type { DaemonClientOptions } from "./client.js"; + +export type StreamDaemonOptions = DaemonClientOptions & { + onData: (data: unknown) => void; + /** Abort / cancel the stream (closes the socket). */ + signal?: AbortSignal; +}; + +/** + * Long-lived `stream` op: first frame is a DaemonResponse; subsequent frames + * are {@link DaemonStreamFrame} until `end` or the socket closes. + */ +export async function streamDaemon( + params: DaemonRequest["params"], + options: StreamDaemonOptions, +): Promise { + const socketPath = options.socketPath ?? getDaemonSocketPath(); + const timeoutMs = options.timeoutMs ?? 60_000; + const id = randomUUID(); + const token = options.token ?? getDaemonTokenFromEnv(); + const request: DaemonRequest = { id, op: "stream", params }; + if (token !== undefined) request.token = token; + + return new Promise((resolve, reject) => { + let settled = false; + let buffer = ""; + let streaming = false; + let timer: ReturnType | undefined; + const socket = new net.Socket(); + + function settle(fn: () => void) { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + socket.removeAllListeners(); + socket.on("error", () => {}); + fn(); + } + + function fail(error: unknown) { + settle(() => { + socket.destroy(); + reject(error); + }); + } + + function succeed() { + settle(() => { + socket.destroy(); + resolve(); + }); + } + + function onAbort() { + succeed(); + } + + function handleLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + + if (!streaming) { + let response: DaemonResponse; + try { + response = JSON.parse(trimmed) as DaemonResponse; + } catch (error) { + fail(error); + return; + } + if (response.id !== id && response.id !== "?") return; + if (!response.ok) { + fail( + new CliExitCodeError( + response.error.exitCode ?? EXIT_CODES.USAGE, + response.error.message, + { code: response.error.code }, + ), + ); + return; + } + streaming = true; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + return; + } + + let frame: DaemonStreamFrame; + try { + frame = JSON.parse(trimmed) as DaemonStreamFrame; + } catch (error) { + fail(error); + return; + } + if (frame.id !== id) return; + if (frame.stream === "data") { + options.onData(frame.data); + return; + } + if (frame.stream === "end") { + succeed(); + } + } + + socket.on("error", (err) => { + if (streaming) { + succeed(); + return; + } + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Cannot reach session daemon at ${socketPath}: ${err.message}`, + { code: "daemon_unreachable" }, + ), + ); + }); + + socket.on("close", () => { + if (settled) return; + // Soft-end after the ok frame; pre-response FIN is unreachable (mirrors + // the error handler and callDaemon's close guard). + if (streaming) { + succeed(); + return; + } + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Session daemon closed the connection before the stream opened`, + { code: "daemon_unreachable" }, + ), + ); + }); + + timer = setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon stream open timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs); + + options.signal?.addEventListener("abort", onAbort, { once: true }); + + socket.once("connect", () => { + socket.write(encodeRequest(request)); + }); + + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/mcp-bin.ts b/clients/mcpi/src/mcp-bin.ts new file mode 100644 index 0000000000..fcda903255 --- /dev/null +++ b/clients/mcpi/src/mcp-bin.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { realpathSync } from "fs"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { handleError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./session/mcp.js"; + +export { runMcp }; + +const __filename = fileURLToPath(import.meta.url); + +/** True when this file is the process entry (works through npm-link symlinks). */ +function isMainModule(): boolean { + const entry = process.argv[1]; + if (entry === undefined) return false; + try { + return realpathSync(resolve(entry)) === realpathSync(resolve(__filename)); + } catch { + return resolve(entry) === resolve(__filename); + } +} + +if (isMainModule()) { + runMcp(process.argv) + .then(() => process.exit(0)) + .catch(handleError); +} diff --git a/clients/mcpi/src/session/authorize.ts b/clients/mcpi/src/session/authorize.ts new file mode 100644 index 0000000000..82d3566ee7 --- /dev/null +++ b/clients/mcpi/src/session/authorize.ts @@ -0,0 +1,137 @@ +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, +} from "@inspector/core/client/runner.js"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + eraToVersionNegotiation, + type InspectorClientEnvironment, + type InspectorServerSettings, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js"; +import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; +import { mcpiEmaGuidance } from "./ema.js"; + +/** + * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens + * land in the shared `oauth.json` store, then the daemon can reconnect. + */ +export async function authorizeInFrontend( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, + options?: { storedAuthOnly?: boolean }, +): Promise { + if (!isOAuthCapableServerConfig(serverConfig)) { + return; + } + + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const callbackUrlConfig = parseRunnerOAuthCallbackUrl( + process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); + // Disarmed until connectInspectorWithOAuth's own interactive-OAuth window + // runs — mirrors the one-shot CLI's autoOpenControl (clients/cli/src/cli.ts): + // SDK `auth()` during plain connect() must not print/open before that + // window (or --stored-auth-only) gates it. + const autoOpenControl = { armed: false }; + environment.oauth = { + storage: new NodeOAuthStorage(), + // mcpi always attempts interactive OAuth (see the isTTY override below) — + // whoever is running it (human or agent) may not have a real TTY on + // stdin/stderr. Reword the printed line so an agent knows it must relay + // the link to a human rather than treating "Please navigate to" as + // addressed to itself. + navigation: createCliOAuthNavigation({ + autoOpenControl, + disableAutoOpen: options?.storedAuthOnly, + promptMessage: (hrefDisplay, tty) => + tty + ? `Please navigate to: ${hrefDisplay}` + : `The user needs to navigate to this link to authenticate: ${hrefDisplay}`, + }), + redirectUrlProvider, + }; + + const clientConfig = await loadRunnerClientConfig({}); + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + {}, + ); + + const client = new InspectorClient(serverConfig, { + environment, + clientIdentity: { + name: "inspector-cli", + version: readInspectorVersion(import.meta.url), + }, + initialLoggingLevel: "debug", + progress: false, + sample: false, + elicit: false, + serverSettings, + ...(serverSettings?.protocolEra && { + versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), + }), + ...clientAuthOptions, + }); + + try { + await connectInspectorWithOAuth( + client, + serverConfig, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + { + storedAuthOnly: options?.storedAuthOnly, + // mcpi runs as a front-end for whatever invoked it (human terminal or + // agent subprocess) — always admit interactive OAuth rather than + // refusing when stdin/stderr aren't a real TTY. The CI-hang concern + // behind that gate (see clients/cli/README.md OAuth section) doesn't + // apply here: an agent without a TTY is still expected to relay the + // printed URL to an attended human, not run unattended. --stored-auth-only + // (checked above assertInteractiveOAuthAllowed, so unaffected by this) + // remains the way to opt out of interactive OAuth entirely. + isTTY: true, + autoOpenControl, + }, + ); + } catch (err) { + // An EMA server without active install-level IdP config: interactive + // OAuth cannot fix this, so replace the core error (which points at the + // web Client Settings dialog only) with mcpi-appropriate guidance. + if (isEmaClientNotConfiguredError(err)) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + mcpiEmaGuidance(err.reason), + { code: "auth_required" }, + ); + } + throw err; + } finally { + try { + await client.disconnect(); + } catch { + // best-effort + } + } +} diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts new file mode 100644 index 0000000000..c7b912050e --- /dev/null +++ b/clients/mcpi/src/session/dispatch.ts @@ -0,0 +1,159 @@ +import { callDaemon, ensureDaemon, streamDaemon } from "../daemon/index.js"; +import type { RpcParams, RpcResult } from "../daemon/protocol.js"; +import type { + CliAppInfo, + MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; +import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import { writeSessionOutput } from "./format-session.js"; +import { styleFromOpts } from "@inspector/cli/style.js"; +import { promptElicitation } from "./elicitation-prompt.js"; + +const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); + +/** + * The only two methods whose NDJSON output is a `--verify` conformance report + * rather than `tools/list --app-info` probe lines. Everything else that ever + * returns `kind: "ndjson"` is the app-info shape, so this is a short + * allow-list rather than the other way round. + */ +const NDJSON_VARIANTS = new Set(["skills/list", "skills/get"]); + +export type SessionDispatchOpts = { + format?: OutputFormat; + plain?: boolean; + session?: string; + requireExplicit: boolean; +}; + +/** + * Run one session MCP method via daemon `rpc` or `stream`. + */ +export async function dispatchSessionRpc( + method: string, + methodArgs: MethodArgs, + opts: SessionDispatchOpts, +): Promise { + const format: OutputFormat = opts.format ?? "text"; + const style = styleFromOpts({ plain: opts.plain, format }); + const params: RpcParams = { + ...methodArgs, + format, + method, + name: stripAt(opts.session), + requireExplicit: opts.requireExplicit, + }; + + const { socketPath } = await ensureDaemon(); + + if (STREAM_METHODS.has(method)) { + const ac = new AbortController(); + const onSignal = () => ac.abort(); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + try { + await streamDaemon(params, { + socketPath, + signal: ac.signal, + onData: (data) => { + void writeSessionOutput( + { format, style }, + { + kind: "stream-event", + data, + }, + ); + }, + }); + } finally { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + } + return; + } + + const ac = new AbortController(); + const onSignal = () => ac.abort(); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + let outcome: RpcResult; + try { + outcome = await callDaemon("rpc", params, { + socketPath, + signal: ac.signal, + onElicitation: (frame) => + promptElicitation(frame, { + style, + // Prompting only needs a readable stdin and a text-based reply + // channel, not an actual TTY — an agent relaying prompts to a human + // (or answering directly) over a plain pipe works the same way a + // human at a terminal does. `--format json` is still excluded since + // stdout is a single machine-readable payload there, not a place to + // interleave prompts. A stdin that's already closed (e.g. ` { + const { style } = opts; + + if (frame.mode === "form") { + const fields = parseFormSchema(frame.requestedSchema); + if (!fields) { + // Schema outside the spec's restricted primitive-field shape — + // shouldn't happen from a well-behaved server; decline clearly rather + // than silently guessing at field values. + process.stderr.write( + style.yellow( + "This server's form request uses a schema mcpi doesn't support " + + "— declining.\n", + ) + ` ${frame.message}\n`, + ); + return declineResponse(frame); + } + + if (!opts.interactive) { + process.stderr.write( + style.yellow( + "This server is asking for form input, which isn't supported " + + "with --format json — declining.\n", + ) + ` ${frame.message}\n`, + ); + return declineResponse(frame); + } + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const outcome = await promptForm(rl, frame.message, fields, style); + if (outcome.action === "accept") { + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + content: outcome.content, + }; + } + if (outcome.action === "decline") return declineResponse(frame); + return cancelResponse(frame); + } catch { + return cancelResponse(frame); + } finally { + rl.close(); + } + } + + if (!opts.interactive) { + process.stderr.write( + style.yellow( + "This server is asking for input via a URL (elicitation), which " + + "isn't supported with --format json — cancelling.\n", + ) + + ` ${frame.message}\n` + + (frame.url ? ` ${frame.url}\n` : ""), + ); + return cancelResponse(frame); + } + + process.stderr.write( + "\n" + + style.bold("Action required: ") + + frame.message + + "\n" + + " " + + style.link(frame.url ?? "", frame.url) + + "\n\n", + ); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await Promise.race([ + rl.question( + "Open the URL above, complete it, then press Enter to continue " + + "(or type 'c' to cancel): ", + ), + watchForClose(rl), + ]); + if (answer.trim().toLowerCase() === "c") { + return cancelResponse(frame); + } + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + }; + } catch { + return cancelResponse(frame); + } finally { + rl.close(); + } +} diff --git a/clients/mcpi/src/session/ema.ts b/clients/mcpi/src/session/ema.ts new file mode 100644 index 0000000000..d922b80420 --- /dev/null +++ b/clients/mcpi/src/session/ema.ts @@ -0,0 +1,235 @@ +import { + clearEmaIdpSession, + getEmaIdpLoginState, + normalizeIdpIssuer, + type EmaIdpLoginState, +} from "@inspector/core/auth/ema/index.js"; +import type { EmaClientNotConfiguredReason } from "@inspector/core/auth/ema/clientConfigError.js"; +import { + completeIdpOidcAuthorization, + startIdpOidcAuthorization, +} from "@inspector/core/auth/ema/idpOidc.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { + NodeOAuthStorage, + runRunnerInteractiveOAuth, +} from "@inspector/core/auth/node/index.js"; +import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { getClientConfigFilePath } from "@inspector/core/client/index.js"; +import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; +import type { EnterpriseManagedAuthIdpConfig } from "@inspector/core/client/types.js"; +import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +/** Where install-level EMA IdP config lives (honours MCP_CLIENT_CONFIG_PATH). */ +function clientConfigPath(): string { + return getClientConfigFilePath( + process.env.MCP_CLIENT_CONFIG_PATH?.trim() || undefined, + ); +} + +/** + * mcpi-flavoured guidance for a missing/disabled EMA client configuration. + * The core `EmaClientNotConfiguredError` message points at the web Client + * Settings dialog; mcpi users may equally well edit `client.json` directly, + * so name both, with the resolved path. + */ +export function mcpiEmaGuidance(reason: EmaClientNotConfiguredReason): string { + const path = clientConfigPath(); + if (reason === "disabled") { + return ( + "Enterprise-managed auth (EMA) is configured but disabled. Enable it in " + + "the web Inspector's Client Settings, or set " + + `enterpriseManagedAuth.enabled to true in ${path}.` + ); + } + return ( + "Enterprise-managed auth (EMA) is not configured. Configure the " + + "enterprise IdP (issuer, client ID, client secret) in the web Inspector's " + + `Client Settings, or add an enterpriseManagedAuth block to ${path}.` + ); +} + +export type EmaStatus = { + /** Resolved client.json path the config was read from. */ + clientConfigPath: string; + /** An IdP block exists in client.json (even if disabled). */ + configured: boolean; + /** Configured and not explicitly disabled. */ + enabled: boolean; + issuer?: string; + clientId?: string; + /** IdP session state; "unconfigured" when no IdP block exists. */ + loginState: EmaIdpLoginState | "unconfigured"; +}; + +/** Read install-level EMA config; the raw idp block, even when disabled. */ +async function loadEmaIdpConfig(): Promise<{ + idp: EnterpriseManagedAuthIdpConfig | undefined; + enabled: boolean; +}> { + const clientConfig = await loadRunnerClientConfig({}); + const ema = clientConfig.enterpriseManagedAuth; + return { + idp: ema?.idp, + enabled: Boolean(ema?.idp) && ema?.enabled !== false, + }; +} + +function requireIdp( + idp: EnterpriseManagedAuthIdpConfig | undefined, + enabled: boolean, + options?: { allowDisabled?: boolean }, +): EnterpriseManagedAuthIdpConfig { + if (!idp) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + mcpiEmaGuidance("not_configured"), + { + code: "usage", + }, + ); + } + if (!enabled && !options?.allowDisabled) { + throw new CliExitCodeError(EXIT_CODES.USAGE, mcpiEmaGuidance("disabled"), { + code: "usage", + }); + } + return idp; +} + +/** EMA configuration + IdP session state for `auth/ema-status`. */ +export async function getEmaStatus(): Promise { + const { idp, enabled } = await loadEmaIdpConfig(); + if (!idp) { + return { + clientConfigPath: clientConfigPath(), + configured: false, + enabled: false, + loginState: "unconfigured", + }; + } + const storage = new NodeOAuthStorage(); + const loginState = await getEmaIdpLoginState(storage, idp.issuer); + return { + clientConfigPath: clientConfigPath(), + configured: true, + enabled, + issuer: normalizeIdpIssuer(idp.issuer), + clientId: idp.clientId, + loginState, + }; +} + +export type EmaLogoutResult = { issuer: string }; + +/** + * Sign out of the enterprise IdP: clears the cached IdP OIDC session and all + * EMA-minted resource-server tokens. Works even when EMA is disabled (state + * cleanup should never be blocked by the enabled flag). + */ +export async function emaLogout(): Promise { + const { idp, enabled } = await loadEmaIdpConfig(); + const active = requireIdp(idp, enabled, { allowDisabled: true }); + const storage = new NodeOAuthStorage(); + await clearEmaIdpSession(storage, active.issuer); + resetNodeOAuthStorageCache(); + return { issuer: normalizeIdpIssuer(active.issuer) }; +} + +export type EmaLoginResult = { + issuer: string; + loginState: EmaIdpLoginState; + alreadyLoggedIn: boolean; +}; + +/** + * Sign in to the enterprise IdP (EMA leg 1 only — no server required): print + * the IdP authorization URL, wait on the loopback callback, and exchange the + * code for an IdP session. Subsequent connects to EMA servers mint resource + * tokens silently from this session. + * + * Non-TTY (agent-attended) callers get wording that directs the agent to + * relay the link to the human user, mirroring `authorizeInFrontend`. SIGINT / + * SIGTERM and the callback timeout are handled by + * {@link runRunnerInteractiveOAuth}. + */ +export async function emaLogin(options?: { + /** Clear any existing IdP session (and EMA server tokens) first. */ + relogin?: boolean; +}): Promise { + const { idp, enabled } = await loadEmaIdpConfig(); + const active = requireIdp(idp, enabled); + const issuer = normalizeIdpIssuer(active.issuer); + const storage = new NodeOAuthStorage(); + + if (options?.relogin) { + await clearEmaIdpSession(storage, active.issuer); + } else if ( + (await getEmaIdpLoginState(storage, active.issuer)) === "logged_in" + ) { + return { issuer, loginState: "logged_in", alreadyLoggedIn: true }; + } + + const callbackUrlConfig = parseRunnerOAuthCallbackUrl( + process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); + // Armed from the start: unlike connect-time OAuth there is no SDK-internal + // auth() phase to guard against — this flow owns its one authorize URL. + const navigation = createCliOAuthNavigation({ + autoOpenControl: { armed: true }, + promptMessage: (hrefDisplay, tty) => + tty + ? `Sign in to your enterprise IdP: ${hrefDisplay}` + : "The user needs to sign in to the enterprise identity provider " + + `(IdP) at this link: ${hrefDisplay}`, + }); + + // Adapter over the server-bound runner-interactive-OAuth surface: EMA leg 1 + // is server-less, so authenticate/completeOAuthFlow map straight onto the + // IdP OIDC start/complete helpers. This reuses the loopback callback + // server, 15-minute timeout, and SIGINT/SIGTERM cancellation. + await runRunnerInteractiveOAuth({ + client: { + authenticate: async () => { + const { authorizationUrl } = await startIdpOidcAuthorization({ + idp: active, + redirectUrl: redirectUrlProvider.redirectUrl, + storage, + }); + navigation.navigateToAuthorization(authorizationUrl); + return authorizationUrl; + }, + /* v8 ignore next 2 -- only reached when options.authorizationUrl is set, which this flow never does */ + beginInteractiveAuthorization: async () => {}, + completeOAuthFlow: async (authorizationCode, iss) => { + await completeIdpOidcAuthorization({ + idp: active, + authorizationCode, + iss, + redirectUrl: redirectUrlProvider.redirectUrl, + storage, + }); + }, + /* v8 ignore next 2 -- only reached when options.authChallenge is set, which this flow never does */ + checkAuthChallengeSatisfied: async () => false, + }, + redirectUrlProvider, + callbackListen: callbackUrlConfig, + }); + resetNodeOAuthStorageCache(); + + return { + issuer, + loginState: await getEmaIdpLoginState(storage, active.issuer), + alreadyLoggedIn: false, + }; +} diff --git a/clients/mcpi/src/session/form-prompt.ts b/clients/mcpi/src/session/form-prompt.ts new file mode 100644 index 0000000000..87931a2c1e --- /dev/null +++ b/clients/mcpi/src/session/form-prompt.ts @@ -0,0 +1,251 @@ +/** + * Interactive terminal renderer for a form-mode elicitation + * (dual-era support, phase 3). Prompts once per field (type-appropriate: + * text, numeric, y/n, numbered single-select, numbered multi-select), + * pre-fills defaults, does light client-side validation (required/length/ + * range), then shows a review step before submitting so the user can + * re-edit any field or cancel outright. + */ +import type { Interface as ReadlineInterface } from "node:readline/promises"; +import type { Style } from "@inspector/cli/style.js"; +import type { FormField } from "./form-schema.js"; + +export type FormOutcome = + | { action: "accept"; content: Record } + | { action: "decline" } + | { action: "cancel" }; + +/** + * A promise that rejects the first time `rl`'s underlying input stream + * closes (EOF on a redirected/piped stdin, or the readline interface being + * closed elsewhere). Racing every `rl.question()` against this means a + * closed-before-answered stdin (e.g. `mcpi ... { + return new Promise((_, reject) => { + rl.once("close", () => + reject(new Error("stdin closed before an answer was given")), + ); + }); +} + +/** `rl.question()`, but rejects instead of hanging if stdin closes first. */ +function ask( + rl: ReadlineInterface, + closed: Promise, + prompt: string, +): Promise { + return Promise.race([rl.question(prompt), closed]); +} + +function formatDefault(field: FormField): string | undefined { + if (field.default === undefined) return undefined; + if (field.kind === "multiselect") { + return (field.default as string[]).join(", "); + } + return String(field.default); +} + +function describeField(field: FormField, style: Style): string { + const req = field.required ? style.yellow(" (required)") : ""; + const desc = field.description ? ` — ${field.description}` : ""; + const def = formatDefault(field); + const defHint = def !== undefined ? style.dim(` [default: ${def}]`) : ""; + return `${style.bold(field.title)}${req}${desc}${defHint}`; +} + +/** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */ +async function promptField( + rl: ReadlineInterface, + closed: Promise, + field: FormField, + style: Style, +): Promise { + for (;;) { + if (field.kind === "boolean") { + const def = field.default; + const hint = def === undefined ? "y/n" : def ? "Y/n" : "y/N"; + const raw = ( + await ask(rl, closed, `${describeField(field, style)}\n [${hint}]: `) + ) + .trim() + .toLowerCase(); + if (raw === "" && def !== undefined) return def; + if (raw === "y" || raw === "yes") return true; + if (raw === "n" || raw === "no") return false; + if (raw === "" && !field.required) return undefined; + process.stderr.write(style.red(" Please answer y or n.\n")); + continue; + } + + if (field.kind === "enum" || field.kind === "multiselect") { + const lines = field.choices.map( + (choice, i) => ` ${i + 1}. ${choice.label}`, + ); + const multi = field.kind === "multiselect"; + const prompt = multi + ? "Enter one or more numbers separated by commas" + : "Enter a number"; + const raw = ( + await ask( + rl, + closed, + `${describeField(field, style)}\n${lines.join("\n")}\n ${prompt}: `, + ) + ).trim(); + if (raw === "") { + if (field.default !== undefined) return field.default; + if (!field.required) return undefined; + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + const indices = raw.split(",").map((s) => Number.parseInt(s.trim(), 10)); + if ( + indices.some( + (n) => !Number.isInteger(n) || n < 1 || n > field.choices.length, + ) + ) { + process.stderr.write( + style.red( + ` Enter a number between 1 and ${field.choices.length}.\n`, + ), + ); + continue; + } + const values = indices.map((n) => field.choices[n - 1]!.value); + if (multi) { + const m = field as Extract; + if (m.minItems !== undefined && values.length < m.minItems) { + process.stderr.write(style.red(` Select at least ${m.minItems}.\n`)); + continue; + } + if (m.maxItems !== undefined && values.length > m.maxItems) { + process.stderr.write(style.red(` Select at most ${m.maxItems}.\n`)); + continue; + } + return values; + } + return values[0]; + } + + if (field.kind === "number") { + const def = field.default; + const raw = ( + await ask( + rl, + closed, + `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, + ) + ).trim(); + if (raw === "") { + if (def !== undefined) return def; + if (!field.required) return undefined; + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + const n = Number(raw); + if ( + Number.isNaN(n) || + (field.integer && !Number.isInteger(n)) || + (field.minimum !== undefined && n < field.minimum) || + (field.maximum !== undefined && n > field.maximum) + ) { + const range = + field.minimum !== undefined || field.maximum !== undefined + ? ` (${field.minimum ?? "-∞"}..${field.maximum ?? "∞"})` + : ""; + process.stderr.write( + style.red( + ` Enter a valid ${field.integer ? "integer" : "number"}${range}.\n`, + ), + ); + continue; + } + return n; + } + + // string + const def = field.default; + const raw = await ask( + rl, + closed, + `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, + ); + const value = raw === "" && def !== undefined ? def : raw; + if (value === "" && field.required) { + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + if (value === "" && !field.required) return undefined; + if (field.minLength !== undefined && value.length < field.minLength) { + process.stderr.write( + style.red(` Must be at least ${field.minLength} characters.\n`), + ); + continue; + } + if (field.maxLength !== undefined && value.length > field.maxLength) { + process.stderr.write( + style.red(` Must be at most ${field.maxLength} characters.\n`), + ); + continue; + } + return value; + } +} + +/** + * Collect one value per field, then loop on a review step (submit / edit a + * field by name / cancel) until the user submits or cancels. + */ +export async function promptForm( + rl: ReadlineInterface, + message: string, + fields: FormField[], + style: Style, +): Promise { + process.stderr.write(`\n${style.bold("Input requested: ")}${message}\n\n`); + const closed = watchForClose(rl); + + const values = new Map(); + for (const field of fields) { + values.set(field.name, await promptField(rl, closed, field, style)); + } + + for (;;) { + process.stderr.write(`\n${style.bold("Review your answers:")}\n`); + for (const field of fields) { + const v = values.get(field.name); + process.stderr.write( + ` ${field.title}: ${v === undefined ? style.dim("(none)") : String(v)}\n`, + ); + } + const answer = ( + await ask( + rl, + closed, + "\nPress Enter to submit, type a field name to edit it, or 'c' to cancel: ", + ) + ).trim(); + if (answer === "") { + const content: Record = {}; + for (const field of fields) { + const v = values.get(field.name); + if (v !== undefined) content[field.name] = v; + } + return { action: "accept", content }; + } + if (answer.toLowerCase() === "c") { + return { action: "cancel" }; + } + const field = fields.find((f) => f.name === answer); + if (!field) { + process.stderr.write( + style.red(` Unknown field "${answer}". Try again.\n`), + ); + continue; + } + values.set(field.name, await promptField(rl, closed, field, style)); + } +} diff --git a/clients/mcpi/src/session/form-schema.ts b/clients/mcpi/src/session/form-schema.ts new file mode 100644 index 0000000000..9bf47f34ba --- /dev/null +++ b/clients/mcpi/src/session/form-schema.ts @@ -0,0 +1,176 @@ +/** + * Parses a form-mode elicitation `requestedSchema` into a flat list of + * fields mcpi can prompt for. Per the MRTR elicitation spec (2026-07-28), + * form-mode schemas are restricted to a flat object whose properties are + * primitive types only — string, number/integer, boolean, single-select + * enum (`enum` or titled `oneOf`), or multi-select enum (`array` of one of + * those) — so this never needs to handle nesting, arrays of objects, or + * other general JSON Schema features. + * + * Returns `null` if the schema doesn't match that shape (defensive: a + * well-behaved server never sends anything else, but this is untrusted + * wire input from an arbitrary MCP server). + */ + +export type Choice = { value: string; label: string }; + +type FieldExtra = + | { + kind: "string"; + minLength?: number; + maxLength?: number; + format?: string; + default?: string; + } + | { + kind: "number"; + integer: boolean; + minimum?: number; + maximum?: number; + default?: number; + } + | { kind: "boolean"; default?: boolean } + | { kind: "enum"; choices: Choice[]; default?: string } + | { + kind: "multiselect"; + choices: Choice[]; + minItems?: number; + maxItems?: number; + default?: string[]; + }; + +export type FormField = { + name: string; + required: boolean; + title: string; + description?: string; +} & FieldExtra; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseChoicesFromEnum(value: unknown): Choice[] | undefined { + if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { + return undefined; + } + return (value as string[]).map((v) => ({ value: v, label: v })); +} + +function parseChoicesFromOneOf(value: unknown): Choice[] | undefined { + if (!Array.isArray(value)) return undefined; + const choices: Choice[] = []; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.const !== "string") return undefined; + choices.push({ + value: entry.const, + label: typeof entry.title === "string" ? entry.title : entry.const, + }); + } + return choices; +} + +function parseField(prop: unknown): FieldExtra | null { + if (!isRecord(prop)) return null; + const type = prop.type; + + if (type === "boolean") { + return { + kind: "boolean", + default: typeof prop.default === "boolean" ? prop.default : undefined, + }; + } + + if (type === "number" || type === "integer") { + return { + kind: "number", + integer: type === "integer", + minimum: typeof prop.minimum === "number" ? prop.minimum : undefined, + maximum: typeof prop.maximum === "number" ? prop.maximum : undefined, + default: typeof prop.default === "number" ? prop.default : undefined, + }; + } + + if (type === "string") { + const enumChoices = parseChoicesFromEnum(prop.enum); + if (enumChoices) { + return { + kind: "enum", + choices: enumChoices, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + if (prop.oneOf !== undefined) { + const oneOfChoices = parseChoicesFromOneOf(prop.oneOf); + if (!oneOfChoices) return null; + return { + kind: "enum", + choices: oneOfChoices, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + return { + kind: "string", + minLength: + typeof prop.minLength === "number" ? prop.minLength : undefined, + maxLength: + typeof prop.maxLength === "number" ? prop.maxLength : undefined, + format: typeof prop.format === "string" ? prop.format : undefined, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + + if (type === "array") { + const items = prop.items; + if (!isRecord(items)) return null; + const choices = + parseChoicesFromEnum(items.enum) ?? parseChoicesFromOneOf(items.anyOf); + if (!choices) return null; + const defaultValue = + Array.isArray(prop.default) && + prop.default.every((v) => typeof v === "string") + ? (prop.default as string[]) + : undefined; + return { + kind: "multiselect", + choices, + minItems: typeof prop.minItems === "number" ? prop.minItems : undefined, + maxItems: typeof prop.maxItems === "number" ? prop.maxItems : undefined, + default: defaultValue, + }; + } + + return null; +} + +/** Parse a `requestedSchema` into an ordered list of {@link FormField}s. */ +export function parseFormSchema( + schema: Record | undefined, +): FormField[] | null { + if (!isRecord(schema)) return null; + const properties = schema.properties; + if (!isRecord(properties)) return null; + const required = Array.isArray(schema.required) + ? (schema.required.filter((v) => typeof v === "string") as string[]) + : []; + + const fields: FormField[] = []; + for (const [name, prop] of Object.entries(properties)) { + const parsed = parseField(prop); + if (!parsed) return null; + const title = + isRecord(prop) && typeof prop.title === "string" ? prop.title : name; + const description = + isRecord(prop) && typeof prop.description === "string" + ? prop.description + : undefined; + fields.push({ + name, + required: required.includes(name), + title, + description, + ...parsed, + } as FormField); + } + return fields; +} diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts new file mode 100644 index 0000000000..0c8aeaa331 --- /dev/null +++ b/clients/mcpi/src/session/format-human.ts @@ -0,0 +1,834 @@ +/** + * Human-readable (markdown-ish) formatters for the session CLI. + * Styling (color / bold / dim / OSC 8 links) is parameterized via {@link Style}. + */ + +import { PLAIN, type Style } from "@inspector/cli/style.js"; + +type JsonObject = Record; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function shortType(schema: unknown): string { + if (!schema || typeof schema !== "object") return "any"; + const s = schema as JsonObject; + const t = s.type; + if (t === "array") { + if (s.items) return `[${shortType(s.items)}]`; + return "[any]"; + } + if (Array.isArray(t)) { + const filtered = t.filter((x) => x !== "null"); + if (filtered.length === 1) return shortTypeName(String(filtered[0])); + return filtered.map((x) => shortTypeName(String(x))).join(" | "); + } + if (Array.isArray(s.enum)) return "enum"; + if (typeof t === "string") return shortTypeName(t); + return "any"; +} + +function shortTypeName(type: string): string { + const map: Record = { + string: "str", + number: "num", + integer: "int", + boolean: "bool", + object: "obj", + array: "[any]", + }; + return map[type] ?? type; +} + +function formatToolParamsInline(schema: unknown): string { + if (!schema || typeof schema !== "object") return "()"; + const s = schema as JsonObject; + const properties = s.properties as Record | undefined; + if (!properties || Object.keys(properties).length === 0) return "()"; + const required = new Set(asArray(s.required)); + const names = Object.keys(properties); + const ordered = [ + ...names.filter((n) => required.has(n)), + ...names.filter((n) => !required.has(n)), + ]; + const shown = ordered.slice(0, 3); + const hidden = ordered.length - shown.length; + const parts = shown.map((name) => { + const typeStr = shortType(properties[name]); + return required.has(name) ? `${name}:${typeStr}` : `${name}?:${typeStr}`; + }); + if (hidden > 0) parts.push("…"); + return `(${parts.join(", ")})`; +} + +function toolHints(tool: JsonObject): string | undefined { + const ann = tool.annotations as JsonObject | undefined; + if (!ann) return undefined; + const hints: string[] = []; + if (ann.readOnlyHint === true) hints.push("read-only"); + if (ann.destructiveHint === true) hints.push("destructive"); + if (ann.idempotentHint === true) hints.push("idempotent"); + if (ann.openWorldHint === true) hints.push("open-world"); + return hints.length > 0 ? hints.join(", ") : undefined; +} + +function code(style: Style, name: string): string { + return `\`${style.bold(name)}\``; +} + +function heading(style: Style, text: string): string { + return style.bold(text); +} + +function descSuffix(style: Style, description: unknown): string { + if (typeof description !== "string" || !description.trim()) return ""; + return style.dim(` — ${description.trim().split("\n")[0]}`); +} + +function formatUri(style: Style, uri: string): string { + if (!uri) return uri; + if (uri.includes("://")) return style.link(uri); + return style.cyan(uri); +} + +function colorLevel(style: Style, level: string): string { + switch (level) { + case "error": + case "critical": + case "alert": + case "emergency": + return style.red(level); + case "warning": + return style.yellow(level); + case "debug": + case "notice": + return style.dim(level); + default: + return style.cyan(level); + } +} + +/** Format tools/list for human display. */ +export function formatToolsHuman( + tools: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Tools (${tools.length}):`)]; + for (const raw of tools) { + const tool = raw as JsonObject; + const name = String(tool.name ?? "?"); + const params = formatToolParamsInline(tool.inputSchema); + const hints = toolHints(tool); + const hintSuffix = hints ? style.dim(` [${hints}]`) : ""; + lines.push( + `* \`${style.bold(name)}${style.cyan(params)}\`${hintSuffix}${descSuffix(style, tool.description)}`, + ); + } + if (tools.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format resources/list. */ +export function formatResourcesHuman( + resources: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Resources (${resources.length}):`)]; + for (const raw of resources) { + const r = raw as JsonObject; + const name = typeof r.name === "string" ? r.name : String(r.uri ?? "?"); + const uri = typeof r.uri === "string" ? r.uri : ""; + const uriPart = uri ? ` (${formatUri(style, uri)})` : ""; + lines.push( + `* ${code(style, name)}${uriPart}${descSuffix(style, r.description)}`, + ); + } + if (resources.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format resources/templates/list. */ +export function formatResourceTemplatesHuman( + templates: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Resource templates (${templates.length}):`)]; + for (const raw of templates) { + const t = raw as JsonObject; + const name = String(t.name ?? "?"); + const uri = typeof t.uriTemplate === "string" ? t.uriTemplate : ""; + const uriPart = uri ? ` (${formatUri(style, uri)})` : ""; + lines.push( + `* ${code(style, name)}${uriPart}${descSuffix(style, t.description)}`, + ); + } + if (templates.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format prompts/list. */ +export function formatPromptsHuman( + prompts: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Prompts (${prompts.length}):`)]; + for (const raw of prompts) { + const p = raw as JsonObject; + const name = String(p.name ?? "?"); + lines.push(`* ${code(style, name)}${descSuffix(style, p.description)}`); + } + if (prompts.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +function formatContentBlock(block: JsonObject, style: Style): string[] { + const lines: string[] = []; + switch (block.type) { + case "text": + lines.push("````"); + lines.push(String(block.text ?? "")); + lines.push("````"); + break; + case "resource_link": + lines.push(heading(style, "Resource link")); + lines.push(`* URI: ${formatUri(style, String(block.uri ?? ""))}`); + if (block.name) lines.push(`* Name: ${String(block.name)}`); + if (block.description) + lines.push(`* Description: ${String(block.description)}`); + if (block.mimeType) lines.push(`* MIME type: ${String(block.mimeType)}`); + break; + case "image": + lines.push( + style.dim( + `[Image: ${String(block.mimeType ?? "unknown")}${ + typeof block.data === "string" + ? `, ${block.data.length} chars base64` + : "" + }]`, + ), + ); + break; + case "audio": + lines.push( + style.dim( + `[Audio: ${String(block.mimeType ?? "unknown")}${ + typeof block.data === "string" + ? `, ${block.data.length} chars base64` + : "" + }]`, + ), + ); + break; + case "resource": { + lines.push(heading(style, "Embedded resource")); + const res = block.resource as JsonObject | undefined; + if (res) { + lines.push(`* URI: ${formatUri(style, String(res.uri ?? ""))}`); + if (res.mimeType) lines.push(`* MIME type: ${String(res.mimeType)}`); + if (typeof res.text === "string") { + lines.push("````"); + lines.push(res.text); + lines.push("````"); + } + } + break; + } + default: + lines.push(JSON.stringify(block, null, 2)); + } + return lines; +} + +function findDuplicateTextBlocks( + content: JsonObject[], + structuredContent: JsonObject, +): Set { + const dupes = new Set(); + const canonical = JSON.stringify(structuredContent); + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (!block || block.type !== "text" || typeof block.text !== "string") + continue; + try { + const parsed: unknown = JSON.parse(block.text.trim()); + if (JSON.stringify(parsed) === canonical) dupes.add(i); + } catch { + // keep + } + } + return dupes; +} + +/** + * Format a CallToolResult (also used for tasks/result) for human display. + */ +export function formatCallToolResultHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const lines: string[] = []; + if (result.isError === true) { + lines.push(style.red(heading(style, "Tool error:"))); + } + + const sc = result.structuredContent as JsonObject | undefined; + const hasStructuredContent = !!sc && Object.keys(sc).length > 0; + const content = asArray(result.content); + const skipIndices = hasStructuredContent + ? findDuplicateTextBlocks(content, sc!) + : new Set(); + const visible = content.filter((_, i) => !skipIndices.has(i)); + + if (visible.length > 0) { + lines.push(heading(style, "Content:")); + for (let i = 0; i < visible.length; i++) { + if (i > 0) lines.push(""); + lines.push(...formatContentBlock(visible[i]!, style)); + } + } + + if (hasStructuredContent && visible.length === 0) { + if (lines.length > 0) lines.push(""); + lines.push(heading(style, "Structured content:")); + lines.push(JSON.stringify(sc, null, 2)); + } + + const meta = result._meta as JsonObject | undefined; + if (meta && Object.keys(meta).length > 0) { + if (lines.length > 0) lines.push(""); + lines.push(style.dim("Metadata:")); + lines.push(style.dim(JSON.stringify(meta, null, 2))); + } + + if (lines.length === 0) return style.dim("(no content)"); + return lines.join("\n"); +} + +/** Format resources/read contents. */ +export function formatResourceReadHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const contents = asArray(result.contents); + if (contents.length === 0) return style.dim("(empty resource)"); + const lines: string[] = [ + heading(style, `Resource contents (${contents.length}):`), + ]; + for (const c of contents) { + lines.push(""); + lines.push(`URI: ${formatUri(style, String(c.uri ?? ""))}`); + if (c.mimeType) lines.push(style.dim(`MIME: ${String(c.mimeType)}`)); + if (typeof c.text === "string") { + lines.push("````"); + lines.push(c.text); + lines.push("````"); + } else if (typeof c.blob === "string") { + lines.push(style.dim(`[Blob: ${c.blob.length} chars base64]`)); + } + } + return lines.join("\n"); +} + +/** Format prompts/get. */ +export function formatPromptResultHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const description = + typeof result.description === "string" ? result.description : undefined; + const messages = asArray(result.messages); + const lines: string[] = []; + if (description) { + lines.push(style.dim(description)); + lines.push(""); + } + lines.push(heading(style, `Messages (${messages.length}):`)); + for (const msg of messages) { + const role = String(msg.role ?? "?"); + lines.push(""); + lines.push(style.cyan(`[${role}]`)); + const content = msg.content; + if (typeof content === "string") { + lines.push("````"); + lines.push(content); + lines.push("````"); + } else if (content && typeof content === "object") { + if (Array.isArray(content)) { + for (const block of content as JsonObject[]) { + lines.push(...formatContentBlock(block, style)); + } + } else { + lines.push(...formatContentBlock(content as JsonObject, style)); + } + } + } + if (messages.length === 0 && !description) return style.dim("(empty prompt)"); + return lines.join("\n"); +} + +/** Format prompts/complete. */ +export function formatCompletionsHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const values = asArray(result.values); + const lines = [heading(style, `Completions (${values.length}):`)]; + for (const v of values) lines.push(`* ${v}`); + if (values.length === 0) lines.push(style.dim("(none)")); + if (result.hasMore === true) lines.push(style.dim("(more available)")); + return lines.join("\n"); +} + +/** Format tasks/list. */ +export function formatTasksHuman( + tasks: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Tasks (${tasks.length}):`)]; + for (const raw of tasks) { + const t = raw as JsonObject; + const id = String(t.taskId ?? t.id ?? "?"); + const status = String(t.status ?? "?"); + const msg = + typeof t.statusMessage === "string" + ? style.dim(` — ${t.statusMessage}`) + : ""; + lines.push(`* ${code(style, id)} ${status}${msg}`); + } + if (tasks.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format tasks/get. */ +export function formatTaskHuman(task: unknown, style: Style = PLAIN): string { + const t = (task ?? {}) as JsonObject; + const lines = [ + `${heading(style, "Task:")} ${code(style, String(t.taskId ?? t.id ?? "?"))}`, + `Status: ${String(t.status ?? "?")}`, + ]; + if (typeof t.statusMessage === "string") { + lines.push(`Message: ${t.statusMessage}`); + } + if (t.createdAt) lines.push(style.dim(`Created: ${String(t.createdAt)}`)); + if (t.lastUpdatedAt) + lines.push(style.dim(`Updated: ${String(t.lastUpdatedAt)}`)); + return lines.join("\n"); +} + +/** Format initialize / server probe. */ +export function formatInitializeHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const info = (result.serverInfo ?? {}) as JsonObject; + const lines = [ + `${heading(style, "Server:")} ${style.bold(String(info.name ?? "(unknown)"))}${ + info.version ? style.dim(` v${String(info.version)}`) : "" + }`, + ]; + if (result.protocolVersion) { + lines.push(`Protocol: ${String(result.protocolVersion)}`); + } + if (typeof result.instructions === "string" && result.instructions.trim()) { + lines.push(""); + lines.push(heading(style, "Instructions:")); + lines.push(result.instructions.trim()); + } + const caps = result.capabilities; + if (caps && typeof caps === "object") { + const keys = Object.keys(caps as JsonObject); + if (keys.length > 0) { + lines.push(""); + lines.push(`${heading(style, "Capabilities:")} ${keys.join(", ")}`); + } + } + return lines.join("\n"); +} + +/** Format roots/list or roots/set. */ +export function formatRootsHuman( + roots: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Roots (${roots.length}):`)]; + for (const raw of roots) { + const r = raw as JsonObject; + const name = typeof r.name === "string" ? style.dim(` (${r.name})`) : ""; + lines.push(`* ${formatUri(style, String(r.uri ?? "?"))}${name}`); + } + if (roots.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format auth/list. */ +export function formatAuthListHuman( + list: { + oauthStatePath?: string; + servers?: unknown[]; + }, + style: Style = PLAIN, +): string { + const servers = Array.isArray(list.servers) ? list.servers : []; + const lines = [ + heading(style, `Stored auth (${servers.length}):`), + style.dim(String(list.oauthStatePath ?? "")), + ]; + for (const raw of servers) { + const s = raw as JsonObject; + const flags: string[] = []; + if (s.hasTokens === true) flags.push("tokens"); + if (s.hasRefreshToken === true) flags.push("refresh"); + const flagText = + flags.length > 0 + ? style.dim(` (${flags.join(", ")})`) + : style.dim(" (no tokens)"); + lines.push(`* ${code(style, String(s.url))}${flagText}`); + } + if (servers.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format auth/ema-status. */ +export function formatEmaStatusHuman( + status: { + clientConfigPath?: string; + configured?: boolean; + enabled?: boolean; + issuer?: string; + clientId?: string; + loginState?: string; + }, + style: Style = PLAIN, +): string { + const lines = [heading(style, "EMA (enterprise-managed auth):")]; + lines.push(style.dim(String(status.clientConfigPath ?? ""))); + if (status.configured !== true) { + lines.push( + "IdP: " + + style.dim( + "(not configured — set enterpriseManagedAuth in client.json or the web Inspector's Client Settings)", + ), + ); + return lines.join("\n"); + } + const client = status.clientId + ? style.dim(` (client: ${status.clientId})`) + : ""; + lines.push(`IdP: ${code(style, String(status.issuer ?? "?"))}${client}`); + lines.push(`Enabled: ${status.enabled === true ? "yes" : style.dim("no")}`); + const loginState = String(status.loginState ?? "none"); + const stateText = + loginState === "logged_in" + ? style.green(loginState) + : style.dim(loginState); + lines.push(`IdP session: ${stateText}`); + return lines.join("\n"); +} + +/** Format servers/list. */ +export function formatServersListHuman( + servers: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Servers (${servers.length}):`)]; + for (const raw of servers) { + const s = raw as JsonObject; + const sessionName = + typeof s.session === "string" && s.session.length > 0 + ? s.session + : undefined; + const sessionMark = sessionName + ? ` ${style.green(`@${sessionName}`)}${s.isMru === true ? style.green(" (MRU)") : ""}` + : ""; + lines.push( + `* ${code(style, String(s.name))} ${style.dim(`[${String(s.type)}]`)} ${style.dim(String(s.detail ?? ""))}${sessionMark}`, + ); + } + if (servers.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format servers/show (one catalog entry). */ +export function formatServerShowHuman( + server: JsonObject, + style: Style = PLAIN, +): string { + const name = String(server.name ?? "?"); + const type = String(server.type ?? "?"); + const detail = String(server.detail ?? ""); + const header = `${heading(style, "Server")} ${code(style, name)} ${style.dim(`[${type}]`)}`; + const body: Record = {}; + if (server.config && typeof server.config === "object") { + body.config = server.config; + } + if (server.settings && typeof server.settings === "object") { + body.settings = server.settings; + } + return [ + header, + detail ? style.dim(detail) : style.dim("(no detail)"), + JSON.stringify(body, null, 2), + ].join("\n"); +} + +/** Format sessions/list. */ +export function formatSessionsListHuman( + sessions: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Sessions (${sessions.length}):`)]; + for (const raw of sessions) { + const s = raw as JsonObject; + const mru = s.isMru === true ? style.green(" (MRU)") : ""; + const era = + s.protocolEra !== undefined + ? style.dim(` [${String(s.protocolEra)}]`) + : ""; + lines.push( + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}`, + ); + } + if (sessions.length === 0) lines.push(style.dim("(none — connect first)")); + return lines.join("\n"); +} + +/** Format a single session info (connect / sessions/use / sessions/show). */ +export function formatSessionInfoHuman( + session: JsonObject, + style: Style = PLAIN, +): string { + const mru = session.isMru === true ? style.green(" (MRU)") : ""; + const lines = [ + `${heading(style, "Session")} ${code(style, `@${String(session.name)}`)}${mru}`, + `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, + ]; + + // Connection details. `protocolEra` is now on every `SessionInfo` (#2298 + // follow-up), so it renders for plain `connect`/`sessions/use` results too; + // `protocolVersion` and everything below it are `sessions/show`-only. + const era = session.protocolEra; + const protocolVersion = session.protocolVersion; + if (era !== undefined || protocolVersion !== undefined) { + const versionSuffix = + protocolVersion !== undefined ? ` (${String(protocolVersion)})` : ""; + lines.push( + `Era: ${style.dim(`${String(era ?? "unknown")}${versionSuffix}`)}`, + ); + } + // Authorization snapshot (connect-time; omitted for stdio / no-auth + // servers — see `SessionInfo.auth`). + const auth = session.auth as JsonObject | undefined; + if (auth !== undefined) { + const method = auth.method === "ema" ? "EMA" : "OAuth"; + const parts = [auth.authorized === true ? "authorized" : "not authorized"]; + if (typeof auth.scope === "string" && auth.scope !== "") { + parts.push(`scope: ${auth.scope}`); + } + if (typeof auth.clientId === "string" && auth.clientId !== "") { + parts.push(`client: ${auth.clientId}`); + } + if (typeof auth.idpSession === "string") { + parts.push(`IdP session: ${auth.idpSession}`); + } + lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`); + } + const serverInfo = session.serverInfo as JsonObject | undefined; + if (serverInfo?.name !== undefined) { + const version = + serverInfo.version !== undefined ? ` v${String(serverInfo.version)}` : ""; + lines.push( + `Server info: ${style.dim(`${String(serverInfo.name)}${version}`)}`, + ); + } + const capabilities = session.capabilities as JsonObject | undefined; + if (capabilities !== undefined) { + const keys = Object.keys(capabilities); + lines.push( + `Capabilities: ${style.dim(keys.length > 0 ? keys.join(", ") : "(none)")}`, + ); + } + const supportedVersions = session.supportedVersions; + if (Array.isArray(supportedVersions) && supportedVersions.length > 0) { + lines.push( + `Supported versions: ${style.dim(supportedVersions.join(", "))}`, + ); + } + if (typeof session.instructions === "string" && session.instructions !== "") { + lines.push(`Instructions: ${style.dim(session.instructions)}`); + } + + return lines.join("\n"); +} + +/** Format tools/list --app-info lines. */ +export function formatAppInfoListHuman( + lines: unknown[], + style: Style = PLAIN, +): string { + const out = [heading(style, `App info (${lines.length} tools):`)]; + for (const raw of lines) { + const info = raw as JsonObject; + const name = String(info.toolName ?? "?"); + if (info.hasApp === true) { + const uri = String(info.resourceUri ?? "ui://?"); + out.push( + `* ${code(style, name)} — ${style.green("app")} (${formatUri(style, uri)})`, + ); + } else { + const err = + typeof info.resourceError === "string" + ? style.dim(` — ${info.resourceError}`) + : style.dim(" — no app"); + out.push(`* ${code(style, name)}${err}`); + } + } + return out.join("\n"); +} + +/** + * Format `skills/list --verify` / `skills/get --verify` NDJSON lines. + * Each line is a {@link SkillVerifyReport}; the caller already computed the + * one-line stderr summary (`summarizeSkillVerification`) shared with the + * one-shot CLI, so this only renders the per-skill breakdown. + */ +export function formatSkillVerifyListHuman( + lines: unknown[], + style: Style = PLAIN, +): string { + const out = [heading(style, `Skill verification (${lines.length}):`)]; + for (const raw of lines) { + const report = raw as JsonObject; + const name = String(report.name ?? "?"); + const uri = String(report.uri ?? ""); + const outcome = report.outcome as string | undefined; + const conformance = asArray(report.conformance); + const frontmatter = asArray(report.frontmatter); + const files = asArray(report.files); + const errorCount = [...conformance, ...frontmatter].filter( + (issue) => issue.severity === "error", + ).length; + const mismatchCount = files.filter( + (file) => file.status === "mismatch" || file.status === "read-error", + ).length; + const verdict = + outcome === "verified" + ? style.green("verified") + : outcome === "incomplete" + ? style.dim("incomplete") + : style.red("failed"); + const detail = + outcome === "verified" + ? "" + : outcome === "incomplete" + ? style.dim( + ` — ${String(report.incomplete ?? "read bounds cut the walk short")}`, + ) + : style.dim( + ` — ${errorCount} issue(s), ${mismatchCount} file mismatch(es)`, + ); + out.push( + `* ${code(style, name)} (${formatUri(style, uri)}) — ${verdict}${detail}`, + ); + } + return out.join("\n"); +} + +/** Format a single app-info probe. */ +export function formatAppInfoHuman( + info: JsonObject, + style: Style = PLAIN, +): string { + const name = String(info.toolName ?? "?"); + if (info.hasApp === true) { + const lines = [ + `Tool ${code(style, name)} ${style.green("has an MCP App")}`, + `Resource: ${formatUri(style, String(info.resourceUri ?? ""))}`, + ]; + if (info.csp) lines.push(style.dim(`CSP: ${JSON.stringify(info.csp)}`)); + return lines.join("\n"); + } + const err = + typeof info.resourceError === "string" + ? info.resourceError + : "No MCP App UI resource (_meta.ui.resourceUri)."; + return `Tool ${code(style, name)} ${style.red("has no MCP App")}\n${style.dim(err)}`; +} + +/** Format a stream event for human display. */ +export function formatStreamEventHuman( + data: unknown, + style: Style = PLAIN, +): string { + if (!data || typeof data !== "object") return String(data); + const ev = data as JsonObject; + if (ev.type === "subscribed") { + return `${heading(style, "Subscribed:")} ${formatUri(style, String(ev.uri ?? ""))}`; + } + if (ev.type === "resources/updated") { + return `${heading(style, "Resource updated:")} ${formatUri(style, String(ev.uri ?? ""))}`; + } + // logging/tail MessageEntry-shaped + if (ev.direction === "notification" && ev.message) { + const msg = ev.message as JsonObject; + const params = (msg.params ?? {}) as JsonObject; + const level = String(params.level ?? "info"); + const logger = params.logger ? style.dim(` ${String(params.logger)}:`) : ""; + const text = String( + params.data ?? params.message ?? JSON.stringify(params), + ); + return `[${colorLevel(style, level)}]${logger} ${text}`; + } + return JSON.stringify(ev, null, 2); +} + +/** + * Dispatch human formatting for an RPC method result. + * Returns null when the caller should fall back to pretty JSON. + */ +export function formatRpcResultHuman( + method: string, + result: JsonObject, + style: Style = PLAIN, +): string | null { + switch (method) { + case "tools/list": + return formatToolsHuman(asArray(result.tools), style); + case "tools/call": + return formatCallToolResultHuman(result, style); + case "resources/list": + return formatResourcesHuman(asArray(result.resources), style); + case "resources/read": + return formatResourceReadHuman(result, style); + case "resources/templates/list": + return formatResourceTemplatesHuman( + asArray(result.resourceTemplates), + style, + ); + case "resources/unsubscribe": + return `${heading(style, "Unsubscribed:")} ${formatUri(style, String(result.uri ?? ""))}`; + case "prompts/list": + return formatPromptsHuman(asArray(result.prompts), style); + case "prompts/get": + return formatPromptResultHuman(result, style); + case "prompts/complete": + return formatCompletionsHuman(result, style); + case "initialize": + return formatInitializeHuman(result, style); + case "logging/setLevel": + return style.green("Logging level updated."); + case "tasks/list": + return formatTasksHuman(asArray(result.tasks), style); + case "tasks/get": + return formatTaskHuman(result.task, style); + case "tasks/cancel": + return `${heading(style, "Cancelled task:")} ${String(result.taskId ?? "")}`; + case "tasks/result": + return formatCallToolResultHuman(result, style); + case "roots/list": + case "roots/set": + return formatRootsHuman(asArray(result.roots), style); + default: + return null; + } +} diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts new file mode 100644 index 0000000000..3f5e6e7c26 --- /dev/null +++ b/clients/mcpi/src/session/format-session.ts @@ -0,0 +1,302 @@ +import { + awaitableError, + awaitableLog, +} from "@inspector/cli/utils/awaitable-log.js"; +import type { SessionInfo } from "../daemon/protocol.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import type { CliAppInfo } from "@inspector/cli/handlers/method-types.js"; +import { + formatAppInfoHuman, + formatAppInfoListHuman, + formatAuthListHuman, + formatEmaStatusHuman, + formatRpcResultHuman, + formatServersListHuman, + formatServerShowHuman, + formatSessionInfoHuman, + formatSessionsListHuman, + formatSkillVerifyListHuman, + formatStreamEventHuman, +} from "./format-human.js"; +import { PLAIN, type Style } from "@inspector/cli/style.js"; + +type JsonObject = Record; + +/** + * Pretty-print JSON for session `--format json`. + * Unlike one-shot, this does **not** wrap in `{ result }` — the payload is the + * MCP / admin object itself (convenient for scripting). + */ +export function formatSessionJson(data: unknown): string { + return JSON.stringify(data, null, 2) + "\n"; +} + +export type SessionWriteKind = + | { + kind: "rpc"; + method: string; + result: JsonObject; + /** + * Auto-collected by `runMethod` for `tools/call` + `--format json`. + * Session output ignores this side-channel (no `{ result, appInfo }` + * envelope); only `result` is printed. `--app-info` probes put the + * info object in `result` itself. + */ + appInfo?: CliAppInfo; + /** For exit-code messages when result.isError. */ + toolName?: string; + } + | { + kind: "ndjson"; + lines: unknown[]; + /** Distinguishes `tools/list --app-info` probe lines from a `--verify` report. */ + variant?: "app-info" | "skill-verify"; + /** `--verify` one-line stderr verdict; absent for `--app-info`. */ + summary?: string; + /** Non-zero when the emitted `--verify` report is itself a failure. */ + exitCode?: number; + } + | { kind: "stream-event"; data: unknown } + | { kind: "servers/list"; servers: unknown[] } + | { kind: "servers/show"; server: JsonObject } + | { kind: "sessions/list"; sessions: unknown[] } + | { kind: "session"; session: SessionInfo | JsonObject } + | { kind: "disconnect"; name: string } + | { kind: "daemon/status"; status: JsonObject } + | { kind: "daemon/stop"; result: JsonObject } + | { + kind: "auth/list"; + list: { oauthStatePath: string; servers: unknown[] }; + } + | { + kind: "auth/clear"; + result: { url?: string; cleared?: number; all?: boolean }; + } + | { + kind: "auth/ema-status"; + status: { + clientConfigPath: string; + configured: boolean; + enabled: boolean; + issuer?: string; + clientId?: string; + loginState: string; + }; + } + | { + kind: "auth/ema-login"; + result: { issuer: string; loginState: string; alreadyLoggedIn: boolean }; + } + | { + kind: "auth/ema-logout"; + result: { issuer: string }; + } + | { kind: "generic"; data: unknown; title?: string }; + +export type SessionWriteOpts = { + format?: OutputFormat; + /** Human-output styling; ignored for `--format json`. Defaults to plain. */ + style?: Style; +}; + +/** + * Write session CLI output honouring `--format text|json`. + * One-shot output paths are unchanged (`emitResult` / `writeFormattedResult`). + */ +export async function writeSessionOutput( + opts: SessionWriteOpts, + payload: SessionWriteKind, +): Promise { + const format: OutputFormat = opts.format === "json" ? "json" : "text"; + const style = opts.style ?? PLAIN; + + if (format === "json") { + await awaitableLog(formatSessionJson(jsonPayload(payload))); + await writeNdjsonSummary(payload); + applyExitCodes(payload); + return; + } + + await awaitableLog(humanPayload(payload, style) + "\n"); + await writeNdjsonSummary(payload); + applyExitCodes(payload); +} + +/** + * `skills/list --verify` / `skills/get --verify`: the one-line verdict goes to + * **stderr**, after the report, in both `--format text` and `--format json` — + * mirrors the one-shot CLI (`consumeMethodOutcome`), so a reader piping stdout + * into `jq` still sees it and a `--format json` caller isn't left without one + * just because the report itself is already structured. + */ +async function writeNdjsonSummary(payload: SessionWriteKind): Promise { + if (payload.kind === "ndjson" && payload.summary) { + await awaitableError(`${payload.summary}\n`); + } +} + +function jsonPayload(payload: SessionWriteKind): unknown { + switch (payload.kind) { + case "rpc": + // Pretty payload only — never the one-shot `{ result[, appInfo] }` wrap. + return payload.result; + case "ndjson": + return payload.lines; + case "stream-event": + return payload.data; + case "servers/list": + return { servers: payload.servers }; + case "servers/show": + return payload.server; + case "sessions/list": + return { sessions: payload.sessions }; + case "session": + return payload.session; + case "disconnect": + return { name: payload.name }; + case "daemon/status": + return payload.status; + case "daemon/stop": + return payload.result; + case "auth/list": + return payload.list; + case "auth/clear": + return payload.result; + case "auth/ema-status": + return payload.status; + case "auth/ema-login": + return payload.result; + case "auth/ema-logout": + return payload.result; + case "generic": + return payload.data; + } +} + +function humanPayload(payload: SessionWriteKind, style: Style): string { + switch (payload.kind) { + case "rpc": { + if (asAppInfoProbe(payload.result)) { + return formatAppInfoHuman(payload.result, style); + } + const formatted = formatRpcResultHuman( + payload.method, + payload.result, + style, + ); + return formatted ?? JSON.stringify(payload.result, null, 2); + } + case "ndjson": + return payload.variant === "skill-verify" + ? formatSkillVerifyListHuman(payload.lines, style) + : formatAppInfoListHuman(payload.lines, style); + case "stream-event": + return formatStreamEventHuman(payload.data, style); + case "servers/list": + return formatServersListHuman(payload.servers, style); + case "servers/show": + return formatServerShowHuman(payload.server, style); + case "sessions/list": + return formatSessionsListHuman(payload.sessions, style); + case "session": + return formatSessionInfoHuman(payload.session as JsonObject, style); + case "disconnect": + return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`; + case "daemon/status": { + const s = payload.status; + if (s.running === false) { + return String(s.message ?? "Daemon is not running."); + } + const sessions = Array.isArray(s.sessions) + ? (s.sessions as unknown[]) + : []; + return [ + `${style.bold("Daemon")} pid ${String(s.pid)}`, + style.dim(`Socket: ${String(s.socketPath ?? "")}`), + formatSessionsListHuman(sessions, style), + ].join("\n"); + } + case "daemon/stop": + if (payload.result.stopping === false) { + return String(payload.result.message ?? "Daemon was not running."); + } + return style.green("Daemon stopping."); + case "auth/list": + return formatAuthListHuman(payload.list, style); + case "auth/clear": + if (payload.result.all === true) { + return style.green( + `Cleared ${String(payload.result.cleared ?? 0)} stored auth entr${ + payload.result.cleared === 1 ? "y" : "ies" + }.`, + ); + } + return `${style.green("Cleared")} \`${style.bold(String(payload.result.url ?? ""))}\``; + case "auth/ema-status": + return formatEmaStatusHuman(payload.status, style); + case "auth/ema-login": + if (payload.result.alreadyLoggedIn) { + return `${style.green("Already signed in")} to \`${style.bold(payload.result.issuer)}\` ${style.dim("(use auth/ema-login --relogin for a fresh session)")}`; + } + return `${style.green("Signed in")} to \`${style.bold(payload.result.issuer)}\``; + case "auth/ema-logout": + return `${style.green("Signed out")} of \`${style.bold(payload.result.issuer)}\` ${style.dim("(EMA server tokens cleared)")}`; + case "generic": { + if (payload.title) { + return `${style.bold(payload.title)}\n${JSON.stringify(payload.data, null, 2)}`; + } + return JSON.stringify(payload.data, null, 2); + } + } +} + +function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined { + if ( + typeof result.hasApp !== "boolean" || + typeof result.toolName !== "string" || + result.content !== undefined || + result.tools !== undefined + ) { + return undefined; + } + // Narrowed by the structural checks above; CliAppInfo adds optional fields. + // `JsonObject`'s index signature doesn't structurally overlap with + // `CliAppInfo`'s concrete shape, so `as` needs the `unknown` bridge. + return result as unknown as CliAppInfo; +} + +function applyExitCodes(payload: SessionWriteKind): void { + if (payload.kind === "ndjson" && payload.exitCode) { + // Report already written above; thrown last so it routes through the + // session CLI's single exit path, same as the one-shot CLI's + // `consumeMethodOutcome` (Copilot). + throw new CliExitCodeError(payload.exitCode, payload.summary ?? "", { + code: + payload.exitCode === EXIT_CODES.SKILL_INCOMPLETE + ? "skills_incomplete" + : "skills_nonconformant", + }); + } + if (payload.kind === "rpc") { + // Only `--app-info` probes (result is the info object) map to NO_APP. + // Auto-collected `payload.appInfo` from tools/call+json must not. + const info = asAppInfoProbe(payload.result); + if (info) { + if (!info.hasApp) { + throw new CliExitCodeError( + EXIT_CODES.NO_APP, + `Tool '${info.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + ); + } + return; + } + if (payload.result.isError === true) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${payload.toolName ?? "tool"}' returned isError:true.`, + { code: "tool_is_error" }, + ); + } + } +} diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts new file mode 100644 index 0000000000..d05638b8fd --- /dev/null +++ b/clients/mcpi/src/session/mcp.ts @@ -0,0 +1,1124 @@ +import { Command, type Command as CommandType } from "commander"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { + ElicitCapabilityMode, + InspectorServerSettings, + ServerProtocolEra, +} from "@inspector/core/mcp/types.js"; +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, +} from "@inspector/core/mcp/types.js"; +import { + loadServerEntries, + parseHeaderPair, + parseKeyValuePair as parseEnvPair, + selectServerEntry, +} from "@inspector/core/mcp/node/index.js"; +import { type LoggingLevel } from "@modelcontextprotocol/client"; +import { LoggingLevelSchema } from "@modelcontextprotocol/core"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { callDaemon, ensureDaemon } from "../daemon/index.js"; +import type { SessionInfo, SessionShowResult } from "../daemon/protocol.js"; +import { + annotateServerEntriesWithSessions, + listServerEntries, + showServerEntry, + summarizeServerConfig, +} from "@inspector/cli/handlers/servers-list.js"; +import { type OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import { + DEFAULT_CONNECT_TIMEOUT_MS, + withConnectTimeout, +} from "@inspector/cli/handlers/connect-timeout.js"; +import { + SESSION_RPC_METHODS, + type MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; +import { authorizeInFrontend } from "./authorize.js"; +import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; +import { resolveToolCallArgs } from "./parse-tool-args.js"; +import { + dispatchSessionRpc, + hoistAtSession, + requireExplicitSession, + stripAt, +} from "./dispatch.js"; +import { writeSessionOutput } from "./format-session.js"; +import { + createPrivateBinding, + formatPrivateEnvExports, +} from "./private-env.js"; +import { + clearAllStoredAuth, + clearStoredAuth, + clearStoredAuthForRelogin, + listStoredAuth, +} from "./stored-auth.js"; +import { styleFromOpts } from "@inspector/cli/style.js"; +import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js"; +import { createInterface } from "node:readline/promises"; + +function isDaemonUnreachable(error: unknown): boolean { + return ( + error instanceof CliExitCodeError && + error.envelope?.code === "daemon_unreachable" + ); +} + +/** Commander help/version exits — text already written; not real failures. */ +function isCommanderDisplayOnly(error: unknown): boolean { + if (error == null || typeof error !== "object") return false; + const code = (error as { code?: unknown }).code; + return ( + code === "commander.help" || + code === "commander.helpDisplayed" || + code === "commander.version" + ); +} + +type GlobalOpts = { + format?: OutputFormat; + plain?: boolean; + session?: string; + catalog?: string; + config?: string; + storedAuthOnly?: boolean; +}; + +function outOpts(opts: GlobalOpts) { + return { + format: opts.format, + style: styleFromOpts({ plain: opts.plain === true, format: opts.format }), + }; +} + +const validLogLevels: LoggingLevel[] = Object.values(LoggingLevelSchema.enum); + +/** + * Session-first CLI entry (`mcpi`). Talks to the implicit session daemon over + * IPC for connect/disconnect/sessions and MCP RPCs; `servers/list` and + * `servers/show` are local (no daemon). + */ +export async function runMcp(argv?: string[]): Promise { + const raw = argv ?? process.argv; + const { argv: rewritten, sessionFromAt } = hoistAtSession(raw); + + const program = new Command(); + program.exitOverride((err) => { + // Help/version already printed. Always throw so Commander does not + // process.exit (which would tear down in-process tests); runMcp treats + // these as success. Bare `mcpi` uses code `commander.help` with exitCode 1 + // — must not reach handleError as an ErrorEnvelope. + if (isCommanderDisplayOnly(err)) throw err; + if (err.exitCode !== 0) throw err; + }); + + program + .name("mcpi") + .description( + "MCP Inspector session CLI — connect once, run many commands against a named session.\n\n" + + "Agent skill for mcpi: install with `npx skills add modelcontextprotocol/inspector --skill mcpi`, or see `agent-help` below.", + ) + .helpOption("-h, --help", "Display help for command") + .helpCommand("help [command]", "Display help for command") + .option( + "--format ", + "Output format: text (default; human-readable) or json (pretty-printed)", + (v: string): OutputFormat => { + if (v !== "text" && v !== "json") { + throw new Error(`--format must be 'text' or 'json'.`); + } + return v; + }, + ) + .option( + "--plain", + "Disable ANSI styling (color, bold/dim, hyperlinks) in human text output", + ) + .option( + "--session ", + "Session name (without required @). Overrides MRU / positional @name.", + ) + .option( + "--catalog ", + "Writable catalog file (default: ~/.mcp-inspector/mcp.json or MCP_CATALOG_PATH)", + ) + .option( + "--config ", + "Read-only session config file (never written or seeded)", + ) + .option( + "--stored-auth-only", + "Never start interactive OAuth; use the shared store if present, otherwise fail.", + ); + + if (sessionFromAt) { + program.setOptionValue("session", sessionFromAt); + } + + program + .command("servers/list") + .description( + "List catalog/config server entries (marks live sessions when the daemon is running; no MCP connection)", + ) + .action(async () => { + const opts = program.opts(); + const envCatalog = process.env.MCP_CATALOG_PATH; + const entries = await listServerEntries({ + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + }); + let sessions: SessionInfo[] = []; + try { + const result = await callDaemon<{ sessions: SessionInfo[] }>( + "sessions/list", + {}, + ); + sessions = result.sessions; + } catch (error) { + if (!isDaemonUnreachable(error)) throw error; + } + await writeSessionOutput(outOpts(opts), { + kind: "servers/list", + servers: annotateServerEntriesWithSessions(entries, sessions), + }); + }); + + program + .command("servers/show") + .description( + "Show one catalog/config entry in detail (no MCP connection; secrets redacted)", + ) + .argument("", "Catalog entry name") + .action(async (name: string) => { + const opts = program.opts(); + const envCatalog = process.env.MCP_CATALOG_PATH; + const entry = await showServerEntry(name, { + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + }); + await writeSessionOutput(outOpts(opts), { + kind: "servers/show", + server: entry, + }); + }); + + registerConnect(program); + registerSessionAdmin(program); + registerAuthCommands(program); + registerRpcCommands(program); + // Keep infra commands last in --help (just before Commander's built-in help). + registerDaemonCommands(program); + registerPrivateCommand(program); + registerAgentHelpCommand(program); + + try { + await program.parseAsync(rewritten); + } catch (error) { + if (isCommanderDisplayOnly(error)) return; + throw error; + } +} + +function registerConnect(program: CommandType): void { + program + .command("connect") + .description("Connect a catalog entry or ad-hoc target as a named session") + .argument( + "[target...]", + "Catalog entry name, or command/URL (use -- for command args)", + ) + .option("--server ", "Server name from catalog/config") + .option( + "-e ", + "Environment variables for the server (KEY=VALUE)", + parseEnvPair, + {}, + ) + .option("--cwd ", "Working directory for stdio server process") + .option( + "--transport ", + "Transport type (sse, http, or stdio)", + (value: string) => { + const valid = ["sse", "http", "stdio"]; + if (!valid.includes(value)) { + throw new Error(`Invalid transport type: ${value}`); + } + return value as "sse" | "http" | "stdio"; + }, + ) + .option("--server-url ", "Server URL for SSE/HTTP transport") + .option( + "--header ", + 'HTTP headers as "HeaderName: Value" pairs', + parseHeaderPair, + {}, + ) + .option( + "--connect-timeout ", + `Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc)`, + (v: string) => { + const n = Number(v); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`--connect-timeout must be a non-negative number.`); + } + return n; + }, + ) + .option( + "--era ", + "Protocol era to negotiate: legacy (default), auto, or modern. " + + "Overrides the catalog/config entry's protocolEra; the only way to " + + "set it for an ad-hoc target, which has no config entry of its own.", + (value: string) => { + const valid: ServerProtocolEra[] = ["legacy", "auto", "modern"]; + if (!valid.includes(value as ServerProtocolEra)) { + throw new Error( + `Invalid --era: ${value}. Use legacy, auto, or modern.`, + ); + } + return value as ServerProtocolEra; + }, + ) + .option( + "--relogin", + "Ignore stored OAuth for this connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. No-op for stdio / servers with no stored entry", + ) + .option( + "--elicit ", + "Elicitation capability to advertise: off, url, form, or both (default). " + + "Overrides the catalog/config entry's elicitCapability; the only way to " + + "set it for an ad-hoc target, which has no config entry of its own. Use " + + "off when the caller of mcpi can't handle an elicitation request, so the " + + "server sees no elicitation capability and can fall back on its own.", + (value: string) => { + const valid: ElicitCapabilityMode[] = ["off", "url", "form", "both"]; + if (!valid.includes(value as ElicitCapabilityMode)) { + throw new Error( + `Invalid --elicit: ${value}. Use off, url, form, or both.`, + ); + } + return value as ElicitCapabilityMode; + }, + ) + .option( + "--ema", + "Treat the server as enterprise-managed (EMA): mint tokens from the " + + "signed-in enterprise IdP session instead of standard OAuth. " + + "Overrides the catalog/config entry's oauth.enterpriseManaged; the " + + "only way to set it for an ad-hoc target. Requires install-level IdP " + + "config (see auth/ema-status) and per-server OAuth client id/secret " + + "from the catalog entry.", + ) + .action(async (target: string[], cmdOpts) => { + const opts = program.opts(); + const { name: positionalSession, rest } = splitSessionTarget(target); + const sessionName = + stripAt(opts.session) ?? + positionalSession ?? + cmdOpts.server?.trim() ?? + rest[0]; + + if (!sessionName) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "connect requires a catalog entry name, --server , or an ad-hoc target.", + { code: "usage" }, + ); + } + + const relogin = cmdOpts.relogin === true; + if (relogin && opts.storedAuthOnly) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "--relogin cannot be combined with --stored-auth-only", + { code: "usage" }, + ); + } + + const adHoc = + rest.length > 1 || + Boolean(cmdOpts.transport) || + Boolean(cmdOpts.serverUrl?.trim()) || + (rest.length === 1 && looksLikeUrl(rest[0]!)); + + const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH; + const serverOptions = { + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + target: adHoc ? (rest.length > 0 ? rest : undefined) : undefined, + transport: cmdOpts.transport as "sse" | "http" | "stdio" | undefined, + serverUrl: cmdOpts.serverUrl as string | undefined, + cwd: cmdOpts.cwd as string | undefined, + env: cmdOpts.e as Record | undefined, + headers: cmdOpts.header as Record | undefined, + }; + + const selectName = adHoc + ? undefined + : ((cmdOpts.server as string | undefined)?.trim() ?? rest[0]); + + const entries = await loadServerEntries(serverOptions); + const selected = selectServerEntry(entries, selectName); + const serverConfig = selected.config; + const serverSettings = withEmaOverride( + withElicitOverride( + withEraOverride( + withConnectTimeout( + selected.settings, + (cmdOpts.connectTimeout as number | undefined) ?? + (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), + ), + cmdOpts.era as ServerProtocolEra | undefined, + ), + cmdOpts.elicit as ElicitCapabilityMode | undefined, + ), + cmdOpts.ema === true ? true : undefined, + ); + const { detail } = summarizeServerConfig(serverConfig); + const name = stripAt(sessionName)!; + + if (relogin && "url" in serverConfig && serverConfig.url) { + await clearStoredAuthForRelogin(serverConfig.url); + } + + const { socketPath } = await ensureDaemon(); + const connectParams = { + name, + serverConfig, + serverSettings, + serverIdentity: detail, + }; + + let result: SessionInfo; + try { + result = await callDaemon("connect", connectParams, { + socketPath, + }); + } catch (error) { + if ( + !(error instanceof CliExitCodeError) || + error.envelope?.code !== "auth_required" + ) { + throw error; + } + if (opts.storedAuthOnly) { + throw error; + } + await authorizeInFrontend(serverConfig, serverSettings, { + storedAuthOnly: false, + }); + // Interactive OAuth can run well past the daemon's idle timeout + // (60s, armed while it holds zero sessions) — a slow human login + // (SSO, MFA) can leave the daemon we ensured above already exited. + // Re-ensure so the retry lands on a live daemon instead of a stale + // socket; ensureDaemon() is a no-op when the existing one still + // answers pings. + const { socketPath: freshSocketPath } = await ensureDaemon(); + result = await callDaemon("connect", connectParams, { + socketPath: freshSocketPath, + }); + } + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); +} + +function registerAuthCommands(program: CommandType): void { + program + .command("auth/list") + .description( + "List server URLs in the shared OAuth store (keys for auth/clear)", + ) + .action(async () => { + const opts = program.opts(); + const list = await listStoredAuth(); + await writeSessionOutput(outOpts(opts), { kind: "auth/list", list }); + }); + + program + .command("auth/clear") + .description( + "Clear stored OAuth state for one server URL (from auth/list) or all entries", + ) + .argument("[key]", "Server URL key from auth/list") + .option("--all", "Clear every stored OAuth server entry") + .option("--yes", "Skip confirmation when using --all") + .action(async (key: string | undefined, cmdOpts) => { + const opts = program.opts(); + const all = cmdOpts.all === true; + if (all && key?.trim()) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear: pass a key or --all, not both", + { code: "usage" }, + ); + } + if (!all && !key?.trim()) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear requires a server URL key (from auth/list) or --all", + { code: "usage" }, + ); + } + if (all) { + if (!cmdOpts.yes) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear --all requires --yes in non-interactive mode", + { code: "usage" }, + ); + } + /* v8 ignore next 22 -- interactive y/N confirm needs a real TTY */ + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await rl.question( + "Clear ALL stored OAuth credentials? [y/N] ", + ); + const ok = + answer.trim().toLowerCase() === "y" || + answer.trim().toLowerCase() === "yes"; + if (!ok) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear --all cancelled", + { code: "usage" }, + ); + } + } finally { + rl.close(); + } + } + const result = await clearAllStoredAuth(); + await writeSessionOutput(outOpts(opts), { + kind: "auth/clear", + result: { all: true, cleared: result.cleared }, + }); + return; + } + const result = await clearStoredAuth(key!); + await writeSessionOutput(outOpts(opts), { + kind: "auth/clear", + result: { url: result.url }, + }); + }); + + program + .command("auth/ema-status") + .description( + "Show enterprise-managed auth (EMA) configuration and IdP login state", + ) + .action(async () => { + const opts = program.opts(); + const status = await getEmaStatus(); + await writeSessionOutput(outOpts(opts), { + kind: "auth/ema-status", + status, + }); + }); + + program + .command("auth/ema-login") + .description( + "Sign in to the enterprise IdP (EMA); subsequent connects to EMA servers mint tokens silently from this session", + ) + .option( + "--relogin", + "Clear the existing IdP session (and EMA server tokens) and sign in fresh", + ) + .action(async (cmdOpts) => { + const opts = program.opts(); + const result = await emaLogin({ relogin: cmdOpts.relogin === true }); + await writeSessionOutput(outOpts(opts), { + kind: "auth/ema-login", + result, + }); + }); + + program + .command("auth/ema-logout") + .description( + "Sign out of the enterprise IdP and clear EMA-minted server tokens", + ) + .action(async () => { + const opts = program.opts(); + const result = await emaLogout(); + await writeSessionOutput(outOpts(opts), { + kind: "auth/ema-logout", + result, + }); + }); +} + +function registerSessionAdmin(program: CommandType): void { + program + .command("disconnect") + .description("Disconnect a session (MRU when omitted on a TTY)") + .argument("[session]", "Optional @name / name to disconnect") + .action(async (sessionArg: string | undefined) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + const { socketPath } = await ensureDaemon(); + const result = await callDaemon<{ name: string }>( + "disconnect", + { + name, + requireExplicit: requireExplicitSession(), + }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "disconnect", + name: result.name, + }); + }); + + program + .command("sessions/list") + .description("List open sessions (marks MRU); does not start the daemon") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon<{ sessions: SessionInfo[] }>( + "sessions/list", + {}, + ); + await writeSessionOutput(outOpts(opts), { + kind: "sessions/list", + sessions: result.sessions, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "sessions/list", + sessions: [], + }); + return; + } + throw error; + } + }); + + program + .command("sessions/use") + .description("Set the MRU session without an MCP RPC") + .argument("", "Session @name / name") + .action(async (sessionArg: string) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + if (!name) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "sessions/use requires a session name", + { code: "usage" }, + ); + } + const { socketPath } = await ensureDaemon(); + const result = await callDaemon( + "sessions/use", + { name }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); + + program + .command("sessions/show") + .description( + "Show session + connection details: server info, capabilities, negotiated protocol era (defaults to MRU)", + ) + .argument("[session]", "Session @name / name (defaults to MRU)") + .action(async (sessionArg: string | undefined) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + const { socketPath } = await ensureDaemon(); + const result = await callDaemon( + "sessions/show", + { name, requireExplicit: requireExplicitSession() }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); +} + +function registerDaemonCommands(program: CommandType): void { + const daemon = program.command("daemon").description("Daemon control"); + + daemon + .command("status") + .description("Show daemon pid, socket, and sessions (does not start it)") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon("daemon/status", {}); + await writeSessionOutput(outOpts(opts), { + kind: "daemon/status", + status: result as Record, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "daemon/status", + status: { + running: false, + message: "Daemon is not running.", + }, + }); + return; + } + throw error; + } + }); + + daemon + .command("stop") + .description("Stop the daemon and disconnect all sessions") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon("daemon/stop", {}); + await writeSessionOutput(outOpts(opts), { + kind: "daemon/stop", + result: result as Record, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "daemon/stop", + result: { + stopping: false, + message: "Daemon was not running.", + }, + }); + return; + } + throw error; + } + }); +} + +function registerPrivateCommand(program: CommandType): void { + program + .command("private") + .description( + 'Print shell exports for a private daemon (eval "$(mcpi private)"). ' + + "Later mcpi commands in that shell use an isolated, token-gated daemon.", + ) + .action(async () => { + const binding = createPrivateBinding(); + await awaitableLog(formatPrivateEnvExports(binding)); + }); +} + +/** + * Locates the repo-root `skills/mcpi/SKILL.md` relative to this module. + * Tries both the built (bundled single-file, `clients/mcpi/build/`) and + * source (`clients/mcpi/src/session/`) layouts, since the two sit at + * different depths from the repo root. + */ +function resolveAgentSkillPath(): string | undefined { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(here, "../../../skills/mcpi/SKILL.md"), + path.resolve(here, "../../../../skills/mcpi/SKILL.md"), + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +function registerAgentHelpCommand(program: CommandType): void { + program + .command("agent-help") + .description( + "Print mcpi's SKILL.md content — a concise, agent-oriented guide for " + + "coding agents/LLMs (also the file `npx skills` installs). Use " + + "--path to print its file location instead of its contents.", + ) + .option("--path", "Print the resolved file path instead of its contents") + .action(async (o: { path?: boolean }) => { + const skillPath = resolveAgentSkillPath(); + if (!skillPath) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Could not locate skills/mcpi/SKILL.md relative to this install.", + { code: "agent_help_not_found" }, + ); + } + if (o.path === true) { + await awaitableLog(skillPath + "\n"); + return; + } + await awaitableLog(readFileSync(skillPath, "utf8")); + }); +} + +function registerRpcCommands(program: CommandType): void { + for (const method of SESSION_RPC_METHODS) { + const cmd = program + .command(method) + .description(`MCP ${method} against the current session`); + + cmd.option( + "--metadata ", + "General metadata as key=value pairs", + parseKeyValue, + {}, + ); + + switch (method) { + case "tools/list": + cmd.option("--app-info", "Emit one NDJSON app-info line per tool"); + cmd.action(async (o) => { + await runRpc(program, method, { + appInfo: o.appInfo === true, + metadata: o.metadata, + }); + }); + break; + case "tools/call": + cmd + .argument("[toolName]", "Tool name") + .argument( + "[toolArgs...]", + "Arguments as key:=value pairs or a JSON object", + ) + .option("--tool-name ", "Tool name") + .option( + "--tool-arg ", + "Tool argument as key=value pair (alternative to key:=value positionals)", + parseKeyValue, + {}, + ) + .option( + "--tool-args-json ", + "Tool arguments as a JSON object (alternative to inline JSON positional)", + ) + .option( + "--tool-metadata ", + "Tool-specific metadata", + parseKeyValue, + {}, + ) + .option("--task", "Task-augmented tool call (callToolStream)") + .option("--app-info", "Probe MCP App metadata only"); + cmd.action( + async ( + toolNamePos: string | undefined, + toolArgsPos: string[] | undefined, + o, + ) => { + const { toolName, toolArg } = resolveToolCallArgs({ + toolNameFlag: o.toolName as string | undefined, + toolNamePos, + toolArgsPos, + toolArgFlag: (o.toolArg ?? {}) as Record, + toolArgsJson: o.toolArgsJson as string | undefined, + }); + await runRpc(program, method, { + toolName, + toolArg, + toolMeta: o.toolMetadata, + metadata: o.metadata, + task: o.task === true, + appInfo: o.appInfo === true, + }); + }, + ); + break; + case "resources/read": + case "resources/subscribe": + case "resources/unsubscribe": + cmd + .argument("[uri]", "Resource URI") + .option("--uri ", "Resource URI"); + cmd.action(async (uriPos: string | undefined, o) => { + await runRpc(program, method, { + uri: (o.uri as string | undefined) ?? uriPos, + metadata: o.metadata, + }); + }); + break; + case "skills/list": + cmd.option( + "--verify", + "Run the SEP-2640 conformance and digest checks over every skill returned", + ); + cmd.action(async (o) => { + await runRpc(program, method, { + verify: o.verify === true, + metadata: o.metadata, + }); + }); + break; + case "skills/get": + cmd + .argument("[uri]", "Skill URI") + .option("--uri ", "Skill URI") + .option( + "--verify", + "Run the SEP-2640 conformance and digest checks over this skill", + ); + cmd.action(async (uriPos: string | undefined, o) => { + await runRpc(program, method, { + uri: (o.uri as string | undefined) ?? uriPos, + verify: o.verify === true, + metadata: o.metadata, + }); + }); + break; + case "prompts/get": + cmd + .argument("[promptName]", "Prompt name") + .option("--prompt-name ", "Prompt name") + .option( + "--prompt-args ", + "Prompt arguments", + parseKeyValue, + {}, + ); + cmd.action(async (promptPos: string | undefined, o) => { + await runRpc(program, method, { + promptName: (o.promptName as string | undefined) ?? promptPos, + promptArgs: (o.promptArgs ?? {}) as Record, + metadata: o.metadata, + }); + }); + break; + case "prompts/complete": + cmd + .option("--complete-ref-type ", "ref/prompt or ref/resource") + .option("--complete-ref ", "Prompt name or resource URI") + .option("--complete-arg-name ", "Argument name") + .option("--complete-arg-value ", "Partial value", ""); + cmd.action(async (o) => { + const refType = o.completeRefType as string | undefined; + if (refType !== "ref/prompt" && refType !== "ref/resource") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "prompts/complete requires --complete-ref-type ref/prompt|ref/resource", + { code: "usage" }, + ); + } + await runRpc(program, method, { + completeRefType: refType, + completeRef: o.completeRef as string | undefined, + completeArgName: o.completeArgName as string | undefined, + completeArgValue: (o.completeArgValue as string | undefined) ?? "", + metadata: o.metadata, + }); + }); + break; + case "logging/setLevel": + cmd + .argument("[level]", "Logging level") + .option("--log-level ", "Logging level"); + cmd.action(async (levelPos: string | undefined, o) => { + const level = (o.logLevel as string | undefined) ?? levelPos; + if (level && !validLogLevels.includes(level as LoggingLevel)) { + throw new Error( + `Invalid log level: ${level}. Valid: ${validLogLevels.join(", ")}`, + ); + } + await runRpc(program, method, { + logLevel: level as LoggingLevel | undefined, + metadata: o.metadata, + }); + }); + break; + case "tasks/get": + case "tasks/cancel": + case "tasks/result": + cmd.argument("[taskId]", "Task id").option("--task-id ", "Task id"); + cmd.action(async (taskPos: string | undefined, o) => { + await runRpc(program, method, { + taskId: (o.taskId as string | undefined) ?? taskPos, + metadata: o.metadata, + }); + }); + break; + case "tasks/update": + // Modern-only (SEP-2663): resumes a task paused on `input_required`. + // `--input-responses` mirrors `roots/set`'s JSON-blob convention + // rather than trying to model arbitrary per-request shapes as flags. + cmd + .argument("[taskId]", "Task id") + .option("--task-id ", "Task id") + .option( + "--input-responses ", + "JSON object keyed by the server's inputRequests id", + ); + cmd.action(async (taskPos: string | undefined, o) => { + await runRpc(program, method, { + taskId: (o.taskId as string | undefined) ?? taskPos, + inputResponsesJson: o.inputResponses as string | undefined, + metadata: o.metadata, + }); + }); + break; + case "roots/set": + cmd.option("--roots-json ", "JSON array of {uri, name?}"); + cmd.action(async (o) => { + await runRpc(program, method, { + rootsJson: o.rootsJson as string | undefined, + metadata: o.metadata, + }); + }); + break; + default: + cmd.action(async (o) => { + await runRpc(program, method, { + metadata: o.metadata, + }); + }); + break; + } + } +} + +async function runRpc( + program: CommandType, + method: string, + methodArgs: MethodArgs, +): Promise { + const opts = program.opts(); + await dispatchSessionRpc(method, methodArgs, { + format: opts.format, + plain: opts.plain === true, + session: opts.session, + requireExplicit: requireExplicitSession(), + }); +} + +/** + * Overlay `--era` onto the settings lifted from the file/ad-hoc target. + * Mirrors `withConnectTimeout`'s shape: only `protocolEra` is overridden, and a + * bare-defaults settings object is synthesized when the target had none (the + * common ad-hoc case, which otherwise has no way to request `auto`/`modern`). + */ +function withEraOverride( + settings: InspectorServerSettings | undefined, + era: ServerProtocolEra | undefined, +): InspectorServerSettings | undefined { + if (era === undefined) return settings; + if (settings) return { ...settings, protocolEra: era }; + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + protocolEra: era, + }; +} + +/** + * Overlay `--elicit` onto the settings lifted from the file/ad-hoc target. + * Mirrors `withEraOverride`: only `elicitCapability` is overridden, and a + * bare-defaults settings object is synthesized when the target had none (the + * common ad-hoc case, which otherwise has no way to request anything but the + * default `both`). + */ +function withElicitOverride( + settings: InspectorServerSettings | undefined, + elicit: ElicitCapabilityMode | undefined, +): InspectorServerSettings | undefined { + if (elicit === undefined) return settings; + if (settings) return { ...settings, elicitCapability: elicit }; + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + elicitCapability: elicit, + }; +} + +/** + * Overlay `--ema` onto the settings lifted from the file/ad-hoc target. + * Mirrors `withEraOverride`: only `enterpriseManaged` is overridden, and a + * bare-defaults settings object is synthesized when the target had none (the + * common ad-hoc case, which otherwise has no way to request EMA). + */ +function withEmaOverride( + settings: InspectorServerSettings | undefined, + ema: true | undefined, +): InspectorServerSettings | undefined { + if (ema === undefined) return settings; + if (settings) return { ...settings, enterpriseManaged: true }; + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + enterpriseManaged: true, + }; +} + +function parseKeyValue( + value: string, + previous: Record = {}, +): Record { + const parts = value.split("="); + const key = parts[0]; + const val = parts.slice(1).join("="); + if (!key || val === undefined || val === "") { + throw new Error( + `Invalid parameter format: ${value}. Use key=value format.`, + ); + } + let parsedValue: JsonValue; + try { + parsedValue = JSON.parse(val) as JsonValue; + } catch { + parsedValue = val; + } + return { ...previous, [key]: parsedValue }; +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function splitSessionTarget(target: string[]): { + name: string | undefined; + rest: string[]; +} { + if (target.length > 0 && target[0]!.startsWith("@")) { + return { name: stripAt(target[0]), rest: target.slice(1) }; + } + return { name: undefined, rest: target }; +} + +export { hoistAtSession } from "./dispatch.js"; diff --git a/clients/mcpi/src/session/parse-tool-args.ts b/clients/mcpi/src/session/parse-tool-args.ts new file mode 100644 index 0000000000..74315516ca --- /dev/null +++ b/clients/mcpi/src/session/parse-tool-args.ts @@ -0,0 +1,134 @@ +import type { JsonValue } from "@inspector/core/mcp/index.js"; + +/** + * Parse session `tools/call` positionals after the tool name: + * - `key:=value` pairs (JSON-typed when the value parses as JSON, else string) + * - a single inline JSON object (`{"message":"Foo"}`) + */ +export function parseToolCallPositionals( + args: string[], +): Record { + if (args.length === 0) return {}; + + const first = args[0]!; + if (first.startsWith("{") || first.startsWith("[")) { + if (args.length > 1) { + throw new Error( + "When using inline JSON, only one argument is allowed after the tool name.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(first); + } catch (e) { + throw new Error( + `Invalid JSON tool arguments: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new Error("Inline JSON tool arguments must be a JSON object."); + } + return parsed as Record; + } + + const out: Record = {}; + for (const pair of args) { + const sep = pair.indexOf(":="); + if (sep === -1) { + throw new Error( + `Invalid tool argument "${pair}". Use key:=value pairs or a JSON object.\n` + + `Examples: message:=hello count:=10 '{"message":"hello"}'`, + ); + } + const key = pair.slice(0, sep); + const rawValue = pair.slice(sep + 2); + if (!key) { + throw new Error( + `Invalid tool argument "${pair}" — missing key before :=`, + ); + } + out[key] = autoParseValue(rawValue); + } + return out; +} + +function autoParseValue(raw: string): JsonValue { + try { + return JSON.parse(raw) as JsonValue; + } catch { + return raw; + } +} + +export type ResolveToolCallArgsInput = { + toolNameFlag?: string; + toolNamePos?: string; + /** Remaining positionals after the tool-name slot. */ + toolArgsPos?: string[]; + toolArgFlag?: Record; + toolArgsJson?: string; +}; + +/** + * Resolve tool name + arguments from positionals and/or legacy flags. + * Styles are mutually exclusive: positionals, `--tool-arg`, or `--tool-args-json`. + */ +export function resolveToolCallArgs(input: ResolveToolCallArgsInput): { + toolName: string | undefined; + toolArg: Record; +} { + const flagArgs = input.toolArgFlag ?? {}; + const hasFlagArgs = Object.keys(flagArgs).length > 0; + const hasJson = input.toolArgsJson !== undefined; + + let toolName = input.toolNameFlag ?? input.toolNamePos; + let positionals = [...(input.toolArgsPos ?? [])]; + + // `tools/call --tool-name echo message:=Foo` — commander puts message:=Foo + // in the toolName slot when the name came from the flag. + if (input.toolNameFlag && input.toolNamePos) { + positionals = [input.toolNamePos, ...positionals]; + toolName = input.toolNameFlag; + } + + const hasPositionals = positionals.length > 0; + const styles = [hasPositionals, hasFlagArgs, hasJson].filter(Boolean).length; + if (styles > 1) { + throw new Error( + "Tool arguments must use one style: key:=value / JSON positionals, " + + "--tool-arg, or --tool-args-json.", + ); + } + + if (hasJson) { + return { + toolName, + toolArg: parseJsonObject(input.toolArgsJson!, "--tool-args-json"), + }; + } + if (hasPositionals) { + return { toolName, toolArg: parseToolCallPositionals(positionals) }; + } + return { toolName, toolArg: flagArgs }; +} + +function parseJsonObject(raw: string, flag: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error( + `${flag} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${flag} must be a JSON object.`); + } + return parsed as Record; +} diff --git a/clients/mcpi/src/session/private-env.ts b/clients/mcpi/src/session/private-env.ts new file mode 100644 index 0000000000..f4b62e26ec --- /dev/null +++ b/clients/mcpi/src/session/private-env.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "node:crypto"; +import { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, +} from "../daemon/paths.js"; + +export type PrivateEnvBinding = { + dir: string; + token: string; +}; + +/** + * Allocate a private daemon directory and mint an IPC token. + * Does not start the daemon (lazy on first `ensureDaemon`). + */ +export function createPrivateBinding(): PrivateEnvBinding { + const dir = createPrivateDaemonDir(); + const token = randomBytes(32).toString("base64url"); + return { dir, token }; +} + +/** + * Shell exports for `eval "$(mcpi private)"` (POSIX sh / bash / zsh). + */ +export function formatPrivateEnvExports(binding: PrivateEnvBinding): string { + return [ + `export ${DAEMON_DIR_ENV}=${shellSingleQuote(binding.dir)}`, + `export ${DAEMON_TOKEN_ENV}=${shellSingleQuote(binding.token)}`, + "", + ].join("\n"); +} + +function shellSingleQuote(value: string): string { + // POSIX-safe: 'foo'\''bar' for embedded quotes. + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/clients/mcpi/src/session/stored-auth.ts b/clients/mcpi/src/session/stored-auth.ts new file mode 100644 index 0000000000..8046212757 --- /dev/null +++ b/clients/mcpi/src/session/stored-auth.ts @@ -0,0 +1,151 @@ +import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js"; +import { + clearAllOAuthClientState, + getStateFilePath, + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +/** Same canonicalisation as one-shot `normalizeServerUrl` (avoid importing cli.ts). */ +function normalizeServerUrl(serverUrl: string): string { + try { + return new URL(serverUrl).href; + } catch { + return serverUrl; + } +} + +export type StoredAuthEntry = { + url: string; + hasTokens: boolean; + hasRefreshToken: boolean; +}; + +export type StoredAuthList = { + oauthStatePath: string; + servers: StoredAuthEntry[]; +}; + +type TokenBlob = { + access_token?: string; + refresh_token?: string; +}; + +function tokenFlagsFromState(state: unknown): { + hasTokens: boolean; + hasRefreshToken: boolean; +} { + if (state == null || typeof state !== "object") { + return { hasTokens: false, hasRefreshToken: false }; + } + const s = state as { + tokens?: TokenBlob; + byIssuer?: Record; + }; + if (s.tokens?.access_token) { + return { + hasTokens: true, + hasRefreshToken: Boolean(s.tokens.refresh_token), + }; + } + for (const slot of Object.values(s.byIssuer ?? {})) { + if (slot?.tokens?.access_token) { + return { + hasTokens: true, + hasRefreshToken: Boolean(slot.tokens.refresh_token), + }; + } + } + return { hasTokens: false, hasRefreshToken: false }; +} + +async function readServersMap( + statePath: string, +): Promise> { + const { readFile } = await import("node:fs/promises"); + try { + const text = await readFile(statePath, "utf8"); + const snapshot = parseOAuthPersistBlob(text); + if (snapshot?.servers && typeof snapshot.servers === "object") { + return snapshot.servers as Record; + } + } catch { + // absent / unreadable + } + return {}; +} + +/** List every server key in the shared OAuth store (tokens optional). */ +export async function listStoredAuth(): Promise { + const oauthStatePath = getStateFilePath(); + const servers = await readServersMap(oauthStatePath); + const entries = Object.keys(servers) + .sort((a, b) => a.localeCompare(b)) + .map((url) => ({ + url, + ...tokenFlagsFromState(servers[url]), + })); + return { oauthStatePath, servers: entries }; +} + +/** + * Resolve a user-supplied key to a stored server URL (exact, then normalised). + */ +export async function resolveStoredAuthKey(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear requires a server URL key (from auth/list) or --all", + { code: "usage" }, + ); + } + const { servers } = await listStoredAuth(); + const urls = servers.map((s) => s.url); + if (urls.includes(trimmed)) return trimmed; + const normalized = normalizeServerUrl(trimmed); + if (urls.includes(normalized)) return normalized; + // Allow clearing a key that is not listed (no-op clear) when it normalises + // to a URL — still useful after partial writes. + if (normalized !== trimmed || /^https?:\/\//i.test(trimmed)) { + return normalized; + } + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `No stored auth entry for '${trimmed}'. Use auth/list to see keys.`, + { code: "usage" }, + ); +} + +/** Clear one server's OAuth state from the shared store. */ +export async function clearStoredAuth(key: string): Promise<{ url: string }> { + const url = await resolveStoredAuthKey(key); + const storage = new NodeOAuthStorage(); + await storage.clear(url); + resetNodeOAuthStorageCache(); + return { url }; +} + +/** Clear every server entry in the shared OAuth store. */ +export async function clearAllStoredAuth(): Promise<{ cleared: number }> { + const before = await listStoredAuth(); + await clearAllOAuthClientState(); + resetNodeOAuthStorageCache(); + return { cleared: before.servers.length }; +} + +/** + * Drop stored OAuth state for an HTTP(S) server URL so the next connect cannot + * silently reuse tokens (`--relogin`). No-op when `serverUrl` is missing + * (stdio / no URL-keyed entry) — interactive login still only runs if auth is required. + */ +export async function clearStoredAuthForRelogin( + serverUrl: string | undefined, +): Promise { + if (!serverUrl?.trim()) return; + const url = normalizeServerUrl(serverUrl.trim()); + const storage = new NodeOAuthStorage(); + await storage.clear(url); + resetNodeOAuthStorageCache(); +} diff --git a/clients/mcpi/tsconfig.json b/clients/mcpi/tsconfig.json new file mode 100644 index 0000000000..b192b9b1eb --- /dev/null +++ b/clients/mcpi/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + // Match clients/cli/tsconfig.json's module/lib *resolution* options (mcpi + // reaches into @inspector/cli/* and @inspector/core/* the same way cli + // does) so core/ and cli/ are validated the same way their own gates + // validate them, rather than under base's stricter + // noUncheckedIndexedAccess, which core/cli were never written against. + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "module": "ESNext", + "noUncheckedIndexedAccess": false, + "paths": { + "@inspector/core/*": ["../../core/*"], + "@inspector/cli/*": ["../cli/src/*"] + } + }, + "include": ["src/**/*", "vitest.config.ts", "tsup.config.ts"], + "exclude": ["node_modules", "**/*.test.ts", "build"] +} diff --git a/clients/mcpi/tsconfig.test.json b/clients/mcpi/tsconfig.test.json new file mode 100644 index 0000000000..823eed9662 --- /dev/null +++ b/clients/mcpi/tsconfig.test.json @@ -0,0 +1,29 @@ +{ + // Typecheck the __tests__ dir (the src-only tsconfig.json excludes tests). + // Mirrors clients/cli/tsconfig.test.json (and clients/web's): the test-server + // barrel is aliased to its source and the module paths below resolve what + // vitest resolves via vitest.shared.mts's projectResolve, so tsc validates + // the tests against the same graph the runner executes. See #1791. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "express"], + "paths": { + "@inspector/core/*": ["../../core/*"], + "@inspector/cli/*": ["../cli/src/*"], + "@modelcontextprotocol/inspector-test-server": [ + "../../test-servers/src/index.ts" + ], + "express": ["./node_modules/@types/express"], + "vitest": ["./node_modules/vitest"] + } + }, + // Some tests import cli's own test helpers by relative path + // (../../cli/__tests__/helpers/*) — tsc follows those transitively, no + // separate include entry needed. + // + // Only the tests root the project; tsc pulls in the `src` they import. The + // src-only tsconfig.json already validates all of `src` (without the + // test-only aliases), so listing it here too would just check it twice. + "include": ["__tests__/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts new file mode 100644 index 0000000000..c3ee701765 --- /dev/null +++ b/clients/mcpi/tsup.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "tsup"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(dirname, "../.."); +const cliSrc = path.resolve(dirname, "../cli/src"); + +export default defineConfig({ + entry: { + "mcp-bin": "src/mcp-bin.ts", + daemon: "src/daemon/run.ts", + }, + format: ["esm"], + outDir: "build", + clean: true, + // No source maps in the published bundle — they roughly double the on-disk + // size and aren't needed at runtime (debug via `npm run dev` on the source). + sourcemap: false, + target: "node22", + platform: "node", + // Bundle core + one-shot CLI internals (handlers, error-handler, OAuth helpers). + // Temporary reach-in until a dedicated shared package exists — see README. + noExternal: [/^@inspector\/core/, /^@inspector\/cli/], + external: [ + "@napi-rs/keyring", + "@modelcontextprotocol/client", + "@modelcontextprotocol/core", + "@modelcontextprotocol/ext-apps", + "commander", + "pino", + "open", + "yaml", + "proper-lockfile", + ], + esbuildOptions(options) { + options.alias = { + "@inspector/core": path.join(repoRoot, "core"), + "@inspector/cli": cliSrc, + }; + }, +}); diff --git a/clients/mcpi/vitest.config.ts b/clients/mcpi/vitest.config.ts new file mode 100644 index 0000000000..08e3063393 --- /dev/null +++ b/clients/mcpi/vitest.config.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import { + NO_RETRY_SETUP, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const { projectResolve } = vitestSharedPaths(dirname); +const cliSrc = path.resolve(dirname, "../cli/src"); + +const baseAliases = Array.isArray(projectResolve.alias) + ? projectResolve.alias + : []; + +export default defineConfig({ + resolve: { + ...projectResolve, + alias: [...baseAliases, { find: "@inspector/cli", replacement: cliSrc }], + }, + test: { + globals: false, + environment: "node", + include: ["__tests__/**/*.test.ts"], + setupFiles: [NO_RETRY_SETUP], + // Shared budgets (#2323). + ...TIMEOUTS, + pool: "forks", + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + include: ["src/**/*.ts"], + exclude: [ + "src/mcp-bin.ts", + "src/daemon/run.ts", + "src/daemon/ipc-glue.ts", + "src/daemon/stream-client.ts", + ], + thresholds: { + perFile: true, + lines: 90, + statements: 90, + functions: 90, + branches: 90, + }, + }, + }, +}); diff --git a/package.json b/package.json index 294ce33425..c17689ae7a 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,16 @@ "web": "node clients/launcher/build/index.js --web", "build:web:runner": "cd clients/web && npm run build:runner", "web:dev": "npm run build:web:runner && node clients/launcher/build/index.js --web --dev", - "build": "npm run build:web && npm run build:cli && npm run build:tui && npm run build:launcher", + "build": "npm run build:web && npm run build:cli && npm run build:mcpi && npm run build:tui && npm run build:launcher", "build:cli": "cd clients/cli && npm run build", + "build:mcpi": "cd clients/mcpi && npm run build", + "build:mcpi:dev": "cd clients/mcpi && npm run build:dev", "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", "local:gate:stages": "npm run local:validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", - "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", + "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../mcpi && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", "local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", @@ -48,7 +50,7 @@ "verify:skills": "node scripts/verify-skills.mjs", "verify:skills:cli": "node scripts/verify-skills-cli.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:mcpi && npm run validate:tui && npm run validate:launcher", "validate:guards": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && npm run test:scripts", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", @@ -62,13 +64,15 @@ "format:check:scripts": "prettier --check \"scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:shared": "prettier --write \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", "format:check:shared": "prettier --check \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", - "format": "npm run format:core && npm run format:scripts && npm run format:shared && cd clients/web && npm run format && cd ../cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", + "format": "npm run format:core && npm run format:scripts && npm run format:shared && cd clients/web && npm run format && cd ../cli && npm run format && cd ../mcpi && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", "validate:cli": "cd clients/cli && npm run validate", + "validate:mcpi": "cd clients/mcpi && npm run validate", "validate:tui": "cd clients/tui && npm run validate", "validate:web": "cd clients/web && npm run validate", "validate:launcher": "cd clients/launcher && npm run validate", - "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:tui && npm run coverage:launcher", + "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:mcpi && npm run coverage:tui && npm run coverage:launcher", "coverage:cli": "cd clients/cli && npm run test:coverage", + "coverage:mcpi": "cd clients/mcpi && npm run test:coverage", "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", diff --git a/scripts/install-clients.mjs b/scripts/install-clients.mjs index 94867b0a45..b7cf34f7b6 100644 --- a/scripts/install-clients.mjs +++ b/scripts/install-clients.mjs @@ -27,7 +27,7 @@ import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const CLIENTS = ["web", "cli", "tui", "launcher"]; +const CLIENTS = ["web", "cli", "mcpi", "tui", "launcher"]; if (process.env.INSPECTOR_SKIP_CLIENT_INSTALL) { console.log( diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 5ddfc395ce..8bee6115b2 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -639,7 +639,7 @@ describe("the gate's name", () => { // that keep it honest: the gate no longer reaches a client's bare `test`, // it still reaches every non-test check `validate` reaches, and `validate` // itself (CI's inner loop) is untouched. - const clients = ["web", "cli", "tui", "launcher"]; + const clients = ["web", "cli", "mcpi", "tui", "launcher"]; const clientScripts = Object.fromEntries( clients.map((c) => [ c, @@ -693,7 +693,7 @@ describe("the gate's name", () => { for (const name of inner) if ( name !== "validate" && - !/^validate:(web|cli|tui|launcher)$/.test(name) + !/^validate:(web|cli|mcpi|tui|launcher)$/.test(name) ) assert.ok(gate.has(name), `local:validate must reach ${name}`); }); diff --git a/scripts/verify-bundle-externals.mjs b/scripts/verify-bundle-externals.mjs index 8b2774f8e6..60d0e8ed19 100644 --- a/scripts/verify-bundle-externals.mjs +++ b/scripts/verify-bundle-externals.mjs @@ -34,8 +34,12 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * Clients that ship a tsup bundle, with the config to read `external` from and - * the build directory to inspect. `clients/launcher` is plain `tsc` — it emits - * no bundle and inlines nothing — so it has nothing to check. + * the build directory to inspect. `entry` names the file whose presence + * proves a build actually ran; it defaults to `index.js` (what web/cli/tui + * each name their single tsup entry) and is overridden only when a client's + * tsup config uses a different entry name, like mcpi's multi-entry `mcp-bin`. + * `clients/launcher` is plain `tsc` — it emits no bundle and inlines nothing — + * so it has nothing to check. */ export const BUNDLED_CLIENTS = [ { @@ -53,6 +57,12 @@ export const BUNDLED_CLIENTS = [ config: "clients/tui/tsup.config.ts", build: "clients/tui/build", }, + { + name: "mcpi", + config: "clients/mcpi/tsup.config.ts", + build: "clients/mcpi/build", + entry: "mcp-bin.js", + }, ]; /** @@ -205,10 +215,11 @@ function main() { ); for (const client of BUNDLED_CLIENTS) { const buildDir = join(repoRoot, client.build); - const entry = join(buildDir, "index.js"); + const entryName = client.entry ?? "index.js"; + const entry = join(buildDir, entryName); if (!existsSync(entry)) { failures.push( - `${client.name}: ${client.build}/index.js is missing — run \`npm run build\` first.`, + `${client.name}: ${client.build}/${entryName} is missing — run \`npm run build\` first.`, ); continue; } diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index c7abd3fa78..d1a73fb75c 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -51,6 +51,7 @@ const MANIFESTS = [ ".", "clients/web", "clients/cli", + "clients/mcpi", "clients/tui", "clients/launcher", ]; diff --git a/scripts/verify-test-timeouts.mjs b/scripts/verify-test-timeouts.mjs index 59f0394ac8..121a40682d 100644 --- a/scripts/verify-test-timeouts.mjs +++ b/scripts/verify-test-timeouts.mjs @@ -93,6 +93,7 @@ export const EXPECTED_PROJECTS = Object.freeze({ cli: EXPECTED_TIMEOUTS, tui: EXPECTED_TIMEOUTS, launcher: EXPECTED_TIMEOUTS, + mcpi: EXPECTED_TIMEOUTS, }); /** @@ -108,6 +109,7 @@ export const CONFIG_ROOTS = Object.freeze([ { root: "clients/cli", projects: ["cli"] }, { root: "clients/tui", projects: ["tui"] }, { root: "clients/launcher", projects: ["launcher"] }, + { root: "clients/mcpi", projects: ["mcpi"] }, ]); /** diff --git a/scripts/verify-test-timeouts.test.mjs b/scripts/verify-test-timeouts.test.mjs index 8c5a727857..d4c5bdec3e 100644 --- a/scripts/verify-test-timeouts.test.mjs +++ b/scripts/verify-test-timeouts.test.mjs @@ -123,6 +123,7 @@ test("a Vitest config this guard does not check is an error", () => { "clients/cli", "clients/tui", "clients/launcher", + "clients/mcpi", "clients/desktop", ]); assert.equal(failures.length, 1); @@ -131,7 +132,7 @@ test("a Vitest config this guard does not check is an error", () => { test("a stale row naming a config that no longer exists is an error", () => { const failures = checkConfigRootCoverage(["clients/web", "clients/cli"]); - assert.equal(failures.length, 2); + assert.equal(failures.length, 3); for (const f of failures) assert.match(f, /has no Vitest config on disk/); }); diff --git a/skills/mcpi/SKILL.md b/skills/mcpi/SKILL.md new file mode 100644 index 0000000000..79b34d9988 --- /dev/null +++ b/skills/mcpi/SKILL.md @@ -0,0 +1,52 @@ +--- +name: mcpi +description: Use the mcpi CLI to connect to Model Context Protocol (MCP) servers and run tools, read resources, list prompts, and more from the command line or from an agent's shell. Use this skill whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. +--- + +# mcpi — MCP Inspector session CLI + +Connect to an MCP server once, then run many commands against that named +session. + +```bash +mcpi connect ./path/to/server.json # config-file entry +mcpi connect https://example.com/mcp # ad-hoc HTTP/SSE target +mcpi connect node server.js # ad-hoc stdio target + +mcpi tools/list +mcpi tools/call arg:=value +mcpi resources/list +mcpi resources/read +mcpi prompts/list + +mcpi @my-session tools/list # target a specific session +mcpi --session my-session tools/list + +mcpi disconnect +``` + +Run `mcpi help` or `mcpi --help` for the full, authoritative list of +commands and flags. + +## Conventions + +- `--format json` outputs JSON; the default, `--format text`, is + human-readable. +- `mcpi sessions/list` shows open sessions; `@name` (prefix on any command) + or `--session ` selects one explicitly when the most-recently-used + session isn't the right one. +- A connected session persists across separate `mcpi` invocations — no need + to reconnect before each command. `mcpi disconnect` ends one session; + `mcpi daemon stop` resets everything. +- `mcpi connect --config path/to/mcp.json` connects a + pre-declared catalog entry (may include auth, headers, protocol-era + overrides); `mcpi connect ` connects an ad-hoc target with + defaults. +- Auth is handled automatically at connect time and stored for reuse (`mcpi + auth/list` / `mcpi auth/clear`); nothing extra is needed for authenticated + HTTP servers beyond `connect` and completing the browser flow if prompted. +- If a server asks a question mid-call (elicitation), mcpi prompts + interactively by default; running non-interactively (no TTY, scripted, or + `--format json`) auto-declines instead of hanging. Pass `--elicit off` on + `connect` if you want a well-behaved server to fall back to its own + defaults instead. diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index 6fa0b9a7e1..90ac5e7f04 100644 --- a/specification/v2_catalog_launch_config.md +++ b/specification/v2_catalog_launch_config.md @@ -512,7 +512,7 @@ G1, G4, and launcher details: [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md). | [#1183](https://github.com/modelcontextprotocol/inspector/issues/1183) — auto-connect | Open | UC5 web ergonomics | | [#1348](https://github.com/modelcontextprotocol/inspector/issues/1348) — import from other clients | Open | UC2 web UI | | [#1435](https://github.com/modelcontextprotocol/inspector/issues/1435) — registry import | Open | UC2 registry path | -| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Open | Session CLI umbrella | +| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Open | Session CLI umbrella — as-built: [v2_cli_v2.md](v2_cli_v2.md) | | [#1352](https://github.com/modelcontextprotocol/inspector/pull/1352) / [#1358](https://github.com/modelcontextprotocol/inspector/pull/1358) | Merged | Flat settings on disk | | [#1356](https://github.com/modelcontextprotocol/inspector/pull/1356) | Merged | Secrets in keychain | diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index fb42b296bd..54f8374435 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -20,7 +20,7 @@ This document describes how those clients are built, wired, and tested today, an ## Non-goals -- **CLI v2 sessions** (connect once, many subcommands) — tracked separately in [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). +- **CLI v2 sessions** (connect once, many subcommands) — as-built in [v2_cli_v2.md](v2_cli_v2.md) (`mcpi` bin session-first; `mcp-inspector --cli` stays one-shot); tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). - **npm workspaces** — v2 uses a fat root package plus per-client `package.json` for dev dependencies; the launcher resolves sibling `build/` outputs via relative paths, not workspace hoisting. - _Why not workspaces:_ `core/` is consumed by **bundling** — a Vite alias for the browser, tsup inlining for the Node clients — not by symlinked package resolution, so workspaces' main benefit (cross-package linking) does not apply. Each client also pins `react` / `@modelcontextprotocol/sdk` to its own `node_modules` (see `vitest.shared.mts`) to avoid dual-package-instance hazards, which hoisting works against. And the published `@modelcontextprotocol/inspector` is a single flat fat package that workspaces would complicate rather than simplify. - _Cost (from-source dev only):_ there is no hoisting, so each client keeps its own `node_modules`. A root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into every client, so a single `npm install` at the repo root populates them all — re-run it after a pull that changes a client's dependencies. The cascade no-ops outside a source checkout (it exits early when running from `node_modules`, and the published tarball ships only each client's `build/`, no client `package.json`), so end users of the published package are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip the cascade (e.g. CI that installs each client itself). @@ -34,7 +34,8 @@ This document describes how those clients are built, wired, and tested today, an | Artifact | Path | Build | Published bin | | ---------- | ------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- | | Launcher | `clients/launcher/` | `tsc` → `build/index.js` | Root `mcp-inspector` → `clients/launcher/build/index.js` | -| CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only) | +| CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only; one-shot) | +| mcpi | `clients/mcpi/` | `tsup` → `build/mcp-bin.js` + `build/daemon.js` | `mcpi` (experimental; not shipped in inspector package) | | TUI | `clients/tui/` | `tsup` → `build/index.js` | `mcp-inspector-tui` (client package only) | | Web runner | `clients/web/server/run-web.ts` | `tsup` (`build:runner`) → `clients/web/build/index.js` | `mcp-inspector-web` (client package only) | @@ -94,7 +95,7 @@ All three clients import from `@inspector/core/...` (mapped to `../../core/` sou ## CLI -**Model:** one-shot — each invocation connects, runs a single `--method`, prints JSON to stdout, disconnects, exits. Same surface as v1.5; session-oriented CLI v2 is future work ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). +**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Session-oriented CLI v2 (`mcpi`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). **Entry:** `clients/cli/src/index.ts` exports `runCli(argv)`; `src/cli.ts` owns Commander parsing and `InspectorClient` orchestration. diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md new file mode 100644 index 0000000000..b901707839 --- /dev/null +++ b/specification/v2_cli_v2.md @@ -0,0 +1,185 @@ +# Inspector CLI v2 (session-oriented) + +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) + +#### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) + +Documentation of the **experimental** session-oriented Inspector CLI (`mcpi`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). `mcpi` is a separate client under `clients/mcpi/` and is **not** shipped in `@modelcontextprotocol/inspector`. + +**Related:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/mcpi/README.md`](../clients/mcpi/README.md), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). + +--- + +## Overview + +| | **One-shot** | **Session** | +| --- | --- | --- | +| Entrypoint | `mcp-inspector --cli` | `mcpi` | +| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands → disconnect | +| Process | In-process only | Short-lived front-end + implicit session daemon (IPC) | +| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/mcpi` (experimental separate client; not shipped in the inspector package) | + +Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (mcpi reaches in via a temporary `@inspector/cli` build alias). One-shot never starts the daemon. `mcpi` does not accept `--method`. + +```bash +mcpi servers/list --config mcp.json +mcpi servers/show my-server --config mcp.json +mcpi connect myserver --config mcp.json +mcpi tools/list +mcpi tools/call search query:=hello +mcpi @other resources/list +mcpi disconnect +``` + +Optional private daemon for one shell (`ssh-agent` style): + +```bash +eval "$(mcpi private)" +mcpi connect myserver --config mcp.json +mcpi tools/list +``` + +--- + +## As-built + +### Entrypoints and layout + +| Piece | Location | +| --- | --- | +| One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | +| Session front-end | `clients/mcpi/src/session/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`) + `mcp-bin.ts` | +| Daemon | `clients/mcpi/src/daemon/` → `clients/mcpi/build/daemon.js` | +| Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | + +``` +mcp-inspector --cli … mcpi … + │ │ + ▼ ▼ + clients/cli clients/mcpi + cli.ts session/mcp.ts + │ │ NDJSON IPC + │ daemon (build/daemon.js) + └──────────┬─────────────┘ + ▼ + clients/cli handlers/run-method.ts → InspectorClient +``` + +### One-shot (`mcp-inspector --cli`) + +Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the session daemon. + +| `--method` | Notes | +| --- | --- | +| `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface (`ONE_SHOT_METHODS`) | +| `servers/list`, `servers/show` | Catalog only (no MCP connect); `servers/show` needs `--server` | + +Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) is a **usage error before connect** — one-shot must not hang on stream outcomes. + +**Output:** `--format text` = pretty JSON of bare result; `json` = `{ result[, appInfo] }` envelope. Exit codes `0`–`5` + stderr `ErrorEnvelope`. + +**Auth:** Interactive OAuth + mid-session recovery in-process (`cliOAuth.ts`); `--stored-auth-only`, `--use-stored-auth`, handoff flags. See [clients/cli/README.md](../clients/cli/README.md). + +### Session CLI (`mcpi`) + +#### Commands + +| Category | Commands | +| --- | --- | +| Catalog | `servers/list`, `servers/show ` | +| Session | `connect` (`--relogin`), `disconnect`, `sessions/list`, `sessions/use` | +| Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | +| Daemon | `private`, `daemon status`, `daemon stop` | +| MCP | `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` | + +**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. + +**Session select:** leading `@name` and/or `--session `. Tool args: `key:=value`, inline JSON, or `--tool-arg` / `--tool-args-json`. + +**Connect forms:** catalog entry / `--server` / ad-hoc URL or command; optional `@name` to override session name (default = entry id). + +#### Output + +| Flag | Behaviour | +| --- | --- | +| `--format text` (default) | Human-readable. On a TTY: ANSI color / bold / dim / OSC 8 links unless `--plain` or `NO_COLOR`. | +| `--format json` | Pretty-printed payload (**no** `{ result }` envelope; never ANSI). | +| Streams | Long-lived until Ctrl-C; human lines or pretty JSON events per `--format`. | + +#### Default session (MRU) + +- Omit `@name` / `--session` → MRU (TTY). +- Explicit `@name` / `--session` always wins. +- Non-TTY: require explicit session unless `MCP_ALLOW_DEFAULT_SESSION=1`. +- `sessions/list`, `sessions/use `; `daemon status` / `sessions/list` do **not** auto-spawn the daemon. + +#### Daemon + +**IPC ops:** `ping`, `connect`, `disconnect`, `sessions/list`, `sessions/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. + +- One `InspectorClient` per named session; auto-spawn on first need; idle exit ~60s after last disconnect **or** after a session-less spawn with no successful connect; `daemon stop` tears down immediately. +- Socket/lock mode `0600` (best-effort). Config (incl. secrets) over IPC after listen — not on daemon argv. +- Errors that are not already `CliExitCodeError` go through `classifyError` (exit-code parity with one-shot). + +| Context | Path | +| --- | --- | +| Shared default | `~/.mcp-inspector/daemon.sock` (+ lock) | +| `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | +| `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | +| Private | `~/.mcp-inspector/private//` from `mcpi private` | + +| Mode | Trust | +| --- | --- | +| **Shared (default)** | No token. Same-UID peer that can open the socket can drive sessions (intentional cross-terminal share). | +| **Private** | `eval "$(mcpi private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | + +#### Auth (session) + +- Same `oauth.json` store as other Inspector clients. +- **Connect-time:** daemon connect → on `auth_required`, front-end `authorizeInFrontend()` (unless `--stored-auth-only`) → retry connect. +- **`--relogin`:** clear any stored OAuth for the server URL before connect; interactive login still runs only if auth is required afterward. No-op for stdio / targets with no URL-keyed store entry (do not reject — same semantics, nothing to clear). +- **Mid-session** step-up during `rpc` / `stream`: **not implemented** (see To-do). Use one-shot, or disconnect / re-auth / reconnect. +- Session `connect` does not expose one-shot OAuth flags (`--client-id`, `--callback-url`, …); env / defaults / `MCP_OAUTH_CALLBACK_URL` only. + +#### One-shot ↔ session mapping + +| One-shot | Session | +| --- | --- | +| `… --catalog mcp.json --server s --method tools/list` | `mcpi connect --catalog mcp.json s` then `mcpi tools/list` | +| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpi tools/call X key:=val` / `'{"…"}'` | +| `… --method servers/list` | `mcpi servers/list` | +| `… --method servers/show --server ` | `mcpi servers/show ` | + +### Testing + +| Client | Runner | Coverage | +| --- | --- | --- | +| One-shot (`clients/cli`) | In-process `runCli()`; thin binary e2e | Per-file ≥90 on `clients/cli/src`. Exclusion: `src/index.ts`. | +| Session (`clients/mcpi`) | In-process `runMcp()`; daemon IPC + stream + private-token tests | Per-file ≥90 on `clients/mcpi/src`. Exclusions: `mcp-bin.ts`, `daemon/run.ts`, `ipc-glue.ts`, `stream-client.ts`. | + +Both are wired into root `validate` / `coverage`. + +--- + +## To-do + +| Item | Notes | +| --- | --- | +| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpi` during `rpc`/`stream`. Connect-time only today. | +| **Daemon singleton / exclusive lock** | `daemon.lock` writes a PID but does not enforce exclusive spawn or stale-PID reclaim. Concurrent `ensureDaemon` can race. | +| **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | +| **Per-socket request serialization** | Accept handler is unbounded per NDJSON line; safe while clients use one request per connection. | +| **Per-session RPC mutex** | Parallel `mcpi` processes against one session can interleave on one `InspectorClient`. | +| **`streamDaemon` post-open errors** | Socket errors after the initial ok frame are treated as soft end. | +| **Coverage gate for `ipc-glue` / `stream-client`** | Behavioral tests exist; files excluded until the race matrix is stably ≥90. | +| **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | +| **Split `registerRpcCommands`** | Large Commander switch in `session/mcp.ts`. | +| **`mcpi daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | +| **Launcher help polish** | Make `mcpi` vs `--cli` unmistakable in launcher `--help` / docs. | +| **Session `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; session authorize uses defaults / env only. | +| **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | +| **Stream fan-out / `mcpi attach`** | One consumer per stream invocation today. | +| **Sampling CLI** | Still TUI/web. mcpi handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. | +| **Ephemeral no-`connect` shortcuts on `mcpi`** | Out of scope (keep two mental models). | +| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | +| **Human `--full` schema dumps** | Optional formatter polish. | From 4853b3badd355973dbca5813765791df09c45d5d Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 22 Sep 2026 23:38:43 -0700 Subject: [PATCH 03/69] fix(mcpi): address review security items 1a-1e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1a — no daemon takeover: a socket that accepts connections is owned by a live daemon; any ping failure (auth, timeout, protocol) now fails loudly instead of unlinking the socket and respawning over it. daemon.lock is a real O_EXCL pid lock with dead-pid reclaim, closing the probe/unlink/bind race between two starting daemons. 1b — terminal escape sanitization: every server-controlled string is sanitized before reaching the terminal in text mode (new session/sanitize.ts: C0/C1 controls except \n\t become visible stand-ins). Wired into the human formatter, the ndjson stderr summary, elicitation prompts (message/url/schema — never protocol ids), and daemon-client error messages. --format json stays verbatim (JSON already escapes controls). 1c — stdio cwd correctness: --cwd is resolved to an absolute path at the caller; stdio connects with no cwd default to the client's cwd (catalog/--cwd still win); the daemon chdirs to its own dir on startup so its inherited cwd is inert. 1d — no silent daemon death: socket paths are validated against sun_path limits up front with an actionable error; private daemon dirs moved to the short $TMPDIR/mcpi-// layout (0700, fits the macOS limit); daemon stderr goes to a 0600 daemon.log whose tail is quoted in start-timeout errors. 1e — hardening: daemon dir created 0700; a token is now always required — generated when the environment doesn't supply one and published to a 0600 daemon.token beside the socket for clients to read, retiring the unauthenticated request path; NDJSON request lines are capped at 1 MiB; SKILL.md/README/spec updated to record the elicitation decision (only --format json auto-declines; URL mode never auto-accepts); the OAuth runner's process-wide SIGINT/SIGTERM handlers are now opt-in (handleSignals) so the TUI keeps Ctrl-C ownership under Ink, with CLI and mcpi opting in. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/cli/src/cliOAuth.ts | 2 + clients/mcpi/README.md | 15 +- .../mcpi/__tests__/daemon-coverage.test.ts | 110 ++++++++++++-- clients/mcpi/__tests__/daemon-paths.test.ts | 42 ++++-- clients/mcpi/__tests__/daemon-private.test.ts | 64 +++++++- clients/mcpi/__tests__/format-session.test.ts | 45 ++++++ clients/mcpi/__tests__/sanitize.test.ts | 76 ++++++++++ clients/mcpi/src/daemon/auth.ts | 24 ++- clients/mcpi/src/daemon/client.ts | 15 +- clients/mcpi/src/daemon/ensure.ts | 88 ++++++++--- clients/mcpi/src/daemon/index.ts | 6 +- clients/mcpi/src/daemon/ipc-glue.ts | 21 +++ clients/mcpi/src/daemon/paths.ts | 81 +++++++--- clients/mcpi/src/daemon/run.ts | 13 ++ clients/mcpi/src/daemon/server.ts | 141 ++++++++++++++---- clients/mcpi/src/daemon/stream-client.ts | 11 +- .../mcpi/src/session/elicitation-prompt.ts | 22 ++- clients/mcpi/src/session/ema.ts | 2 + clients/mcpi/src/session/format-session.ts | 12 +- clients/mcpi/src/session/mcp.ts | 15 +- clients/mcpi/src/session/sanitize.ts | 51 +++++++ .../auth/runner-interactive-oauth.test.ts | 29 ++++ core/auth/node/runner-interactive-oauth.ts | 23 ++- skills/mcpi/SKILL.md | 6 +- specification/v2_cli_v2.md | 11 +- 25 files changed, 788 insertions(+), 137 deletions(-) create mode 100644 clients/mcpi/__tests__/sanitize.test.ts create mode 100644 clients/mcpi/src/session/sanitize.ts diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts index 665c2d0a79..698f429400 100644 --- a/clients/cli/src/cliOAuth.ts +++ b/clients/cli/src/cliOAuth.ts @@ -215,6 +215,8 @@ export async function runCliInteractiveOAuth( createCallbackServer: createOAuthCallbackServer, authorizationUrl: options?.authorizationUrl, authChallenge: options?.authChallenge, + // The CLI has no other Ctrl-C owner; cancel the wait cleanly. + handleSignals: true, }), ); diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index c39e405d20..4ae9c2d1ac 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -135,8 +135,19 @@ legacy server→client `elicitation/create` requests and modern non-task MRTR - **Form mode**: mcpi renders one prompt per field from the schema, with a review step (edit any field again, or submit) before answering. -Non-interactive callers (`--format json`, no TTY, or a script) get an -automatic decline instead of hanging on a prompt. +Only `--format json` callers get an automatic decline (URL mode: cancel) +instead of a prompt. + +> **Decision — who answers a prompt.** Only `--format json` auto-declines +> (its stdout must stay a single machine-readable payload). Everything else — +> including a plain non-TTY stdin — gets a real prompt, which means an agent +> driving mcpi can routinely read a form-mode question and answer on the +> user's behalf. That is deliberate for an inspector tool. URL-mode is +> different: there is never an auto-accept — completion is only ever +> confirmed by an explicit answer to the prompt, because the out-of-band +> action (typically an auth or consent step in a browser) is the user's to +> perform. Use `--elicit off` on `connect` to keep any elicitation from +> being asked at all. By default mcpi advertises **both** modes to the server (`elicit: {url, form}`), matching pre-#1783 behavior. Override this per connection with diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts index ee4a1faebb..4dba9ae0c7 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -6,7 +6,11 @@ import * as path from "node:path"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; import { DaemonServer } from "../src/daemon/server.js"; import { callDaemon } from "../src/daemon/client.js"; -import { ensureDaemon, resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { + ensureDaemon, + readLogTail, + resolveDaemonScriptPath, +} from "../src/daemon/ensure.js"; import { SessionRegistry } from "../src/daemon/sessions.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; @@ -122,12 +126,24 @@ describe("daemon coverage", () => { } }); - it("rejects a second listen when a live daemon owns the socket", async () => { + it("rejects a second daemon while a live one holds the lock", async () => { const d = freshDir(); server = new DaemonServer({ dir: d, idleMs: 0 }); await server.start(); const other = new DaemonServer({ dir: d, idleMs: 0 }); - await expect(other.start()).rejects.toThrow(/already running/); + await expect(other.start()).rejects.toThrow(/held by running pid/); + }); + + it("reclaims a lock left by a dead pid", async () => { + const d = freshDir(); + // No live process can have this pid-space value in practice; write a + // plausible-but-dead pid by spawning nothing and using an exited child. + fs.writeFileSync(path.join(d, "daemon.lock"), "999999999\n"); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + expect(fs.readFileSync(path.join(d, "daemon.lock"), "utf8").trim()).toBe( + String(process.pid), + ); }); it("removes a stale socket before binding", async () => { @@ -457,28 +473,92 @@ describe("daemon coverage", () => { await new Promise((r) => setTimeout(r, 150)); }); - it("ensureDaemon replaces a stale accepting socket", async () => { + it("start-timeout error quotes the daemon's stderr log", async () => { + const d = freshDir(); + // A "daemon" that logs a failure and dies without ever binding a socket + // — the silent-death case the 0600 log exists to explain. + const script = path.join(d, "dying-daemon.js"); + fs.writeFileSync( + script, + 'console.error("boom: could not start"); setTimeout(() => {}, 3000);\n', + ); + await expect( + ensureDaemon({ dir: d, daemonScript: script, readyTimeoutMs: 700 }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_start_timeout" }, + message: expect.stringContaining("boom: could not start"), + }); + }, 15000); + + it("start-timeout error stays clean when the daemon logged nothing", async () => { + const d = freshDir(); + const script = path.join(d, "silent-daemon.js"); + fs.writeFileSync(script, "setTimeout(() => {}, 3000);\n"); + await expect( + ensureDaemon({ dir: d, daemonScript: script, readyTimeoutMs: 700 }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_start_timeout" }, + message: expect.not.stringContaining("Daemon log"), + }); + }, 15000); + + it("readLogTail returns the last lines and empty string when unreadable", () => { + const d = freshDir(); + const logPath = path.join(d, "daemon.log"); + const lines = Array.from({ length: 15 }, (_, i) => `line-${i}`); + fs.writeFileSync(logPath, lines.join("\n") + "\n"); + const tail = readLogTail(logPath); + expect(tail.split("\n")).toHaveLength(10); + expect(tail).toContain("line-14"); + expect(tail).not.toContain("line-4\n"); + expect(readLogTail(path.join(d, "missing.log"))).toBe(""); + }); + + it("ensureDaemon fails loudly when a live listener rejects ping (no takeover)", async () => { + // Regression test for the daemon-takeover hole: a socket that ACCEPTS + // connections is owned by a live process. ensureDaemon must never unlink + // it and install a replacement daemon — it must surface the ping failure. const d = freshDir(); const sock = path.join(d, "daemon.sock"); - const stale = net.createServer((socket) => { + const occupant = net.createServer((socket) => { socket.on("error", () => {}); socket.end(); }); - await new Promise((resolve) => stale.listen(sock, resolve)); + await new Promise((resolve) => occupant.listen(sock, resolve)); try { - const ensured = await ensureDaemon({ - dir: d, - daemonScript: resolveDaemonScriptPath(), - }); - expect(ensured.spawned).toBe(true); - await callDaemon("ping", {}, { socketPath: ensured.socketPath }); - await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); - await new Promise((r) => setTimeout(r, 150)); + await expect( + ensureDaemon({ dir: d, daemonScript: resolveDaemonScriptPath() }), + ).rejects.toThrow(/closed the connection during 'ping'/); + // The occupant's socket must still be in place, untouched. + expect(fs.existsSync(sock)).toBe(true); } finally { - stale.close(); + occupant.close(); } }); + it("ensureDaemon fails loudly on daemon_auth_failed (wrong token is not a stale socket)", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0, requiredToken: "good" }); + await server.start(); + await expect( + ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + token: "wrong", + }), + ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + // The live daemon keeps its socket and still serves the right token. + const pong = await callDaemon( + "ping", + {}, + { + socketPath: server.socketPath, + token: "good", + }, + ); + expect(pong).toBeDefined(); + }); + it("session-less start arms idle and self-reaps", async () => { const d = freshDir(); let shut = false; diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts index 5636493bd6..e81517ec9b 100644 --- a/clients/mcpi/__tests__/daemon-paths.test.ts +++ b/clients/mcpi/__tests__/daemon-paths.test.ts @@ -3,12 +3,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { + assertSocketPathWithinLimit, createPrivateDaemonDir, ensureDaemonDir, getDaemonDir, getDaemonLockPath, getDaemonSocketPath, - getInspectorHome, } from "../src/daemon/paths.js"; import { writeFormattedResult } from "@inspector/cli/handlers/format-output.js"; @@ -16,7 +16,12 @@ describe("daemon paths", () => { const backup: Record = {}; afterEach(() => { - for (const key of ["MCP_INSPECTOR_DAEMON_DIR", "MCP_STORAGE_DIR", "HOME"]) { + for (const key of [ + "MCP_INSPECTOR_DAEMON_DIR", + "MCP_STORAGE_DIR", + "HOME", + "TMPDIR", + ]) { if (key in backup) { if (backup[key] === undefined) delete process.env[key]; else process.env[key] = backup[key]; @@ -59,18 +64,31 @@ describe("daemon paths", () => { fs.rmSync(dir, { recursive: true, force: true }); }); - it("createPrivateDaemonDir nests under ~/.mcp-inspector/private", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-")); - setEnv("HOME", home); - setEnv("MCP_INSPECTOR_DAEMON_DIR", undefined); - setEnv("MCP_STORAGE_DIR", undefined); - expect(getInspectorHome()).toBe(path.join(home, ".mcp-inspector")); + it("createPrivateDaemonDir nests under a short 0700 tmpdir layout", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-t-")); + setEnv("TMPDIR", tmp + path.sep); const dir = createPrivateDaemonDir(); - expect(dir.startsWith(path.join(home, ".mcp-inspector", "private"))).toBe( - true, - ); + // $TMPDIR/mcpi-/<8-hex>; short enough that daemon.sock stays inside + // the platform sun_path limit even for macOS /var/folders tmpdirs. + expect(dir.startsWith(tmp)).toBe(true); + expect(path.basename(dir)).toMatch(/^[0-9a-f]{8}$/); + expect(path.basename(path.dirname(dir))).toMatch(/^mcpi-/); expect(fs.statSync(dir).isDirectory()).toBe(true); - fs.rmSync(home, { recursive: true, force: true }); + if (process.platform !== "win32") { + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.dirname(dir)).mode & 0o777).toBe(0o700); + } + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("assertSocketPathWithinLimit rejects paths over the sun_path limit", () => { + expect(() => + assertSocketPathWithinLimit("/tmp/short/daemon.sock"), + ).not.toThrow(); + const long = "/" + "x".repeat(150) + "/daemon.sock"; + expect(() => assertSocketPathWithinLimit(long)).toThrow( + /too long for this platform/, + ); }); }); diff --git a/clients/mcpi/__tests__/daemon-private.test.ts b/clients/mcpi/__tests__/daemon-private.test.ts index d36f9cc35c..b4cc07ef0d 100644 --- a/clients/mcpi/__tests__/daemon-private.test.ts +++ b/clients/mcpi/__tests__/daemon-private.test.ts @@ -6,10 +6,12 @@ import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-se import { assertDaemonToken, tokensEqual } from "../src/daemon/auth.js"; import { callDaemon } from "../src/daemon/client.js"; import { ensureDaemon } from "../src/daemon/ensure.js"; +import { MAX_REQUEST_LINE_BYTES } from "../src/daemon/ipc-glue.js"; import { createPrivateDaemonDir, DAEMON_DIR_ENV, DAEMON_TOKEN_ENV, + getDaemonTokenPath, } from "../src/daemon/paths.js"; import { DaemonServer } from "../src/daemon/server.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; @@ -73,7 +75,7 @@ describe("mcpi private", () => { }); expectCliSuccess(result); expect(result.stdout).toMatch( - new RegExp(`export ${DAEMON_DIR_ENV}='[^']+/private/[^']+'`), + new RegExp(`export ${DAEMON_DIR_ENV}='[^']+/mcpi-[^/']+/[0-9a-f]{8}'`), ); expect(result.stdout).toMatch( new RegExp(`export ${DAEMON_TOKEN_ENV}='[^']+'`), @@ -94,11 +96,12 @@ describe("mcpi private", () => { expect(text).toContain(`'t'\\''ok'`); }); - it("createPrivateBinding allocates under private/", () => { + it("createPrivateBinding allocates a short 0700 dir under the tmpdir", () => { useTempHome(); const binding = createPrivateBinding(); - expect(binding.dir).toContain(`${path.sep}private${path.sep}`); - expect(binding.dir.startsWith(home!)).toBe(true); + expect(path.basename(binding.dir)).toMatch(/^[0-9a-f]{8}$/); + expect(path.basename(path.dirname(binding.dir))).toMatch(/^mcpi-/); + expect(binding.dir.startsWith(os.tmpdir())).toBe(true); expect(binding.token.length).toBeGreaterThan(20); }); }); @@ -118,20 +121,30 @@ describe("private daemon end-to-end", () => { } }); - it("rejects IPC without the required token and accepts with it", async () => { + it("rejects IPC with a wrong token and accepts with the right one", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-")); const token = "test-token-value"; server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); await server.start(); + // The daemon publishes daemon.token (0600) for same-user clients, so a + // tokenless call auto-discovers it; only a wrong token must fail. await expect( callDaemon( "ping", {}, - { socketPath: server.socketPath, timeoutMs: 2000 }, + { socketPath: server.socketPath, timeoutMs: 2000, token: "wrong" }, ), ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + // Tokenless call discovers the published token file next to the socket. + const discovered = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 2000 }, + ); + expect(discovered.pong).toBe(true); + const pong = await callDaemon<{ pong: boolean }>( "ping", {}, @@ -140,6 +153,45 @@ describe("private daemon end-to-end", () => { expect(pong.pong).toBe(true); }); + it("publishes daemon.token (0600) on start and removes it on stop", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-tok-")); + const token = "published-token"; + server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); + await server.start(); + + const tokenPath = getDaemonTokenPath(dir); + expect(fs.readFileSync(tokenPath, "utf8").trim()).toBe(token); + if (process.platform !== "win32") { + expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600); + } + + await server.stop("stop"); + server = undefined; + expect(fs.existsSync(tokenPath)).toBe(false); + }); + + it("drops a connection whose request line exceeds the cap", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-cap-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const net = await import("node:net"); + const closed = await new Promise((resolve) => { + const socket = net.connect(server!.socketPath, () => { + // One oversized line, never newline-terminated. + socket.write(Buffer.alloc(MAX_REQUEST_LINE_BYTES + 64 * 1024, 0x61)); + }); + const done = () => resolve(true); + socket.once("close", done); + socket.once("error", done); + setTimeout(() => { + socket.destroy(); + resolve(false); + }, 5000).unref(); + }); + expect(closed).toBe(true); + }); + it("session front-end rethrows non-unreachable daemon errors", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-")); const token = "good-token"; diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index 15fc07e1de..36100be9fb 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -594,6 +594,51 @@ describe("writeSessionOutput", () => { expect(stdout).toBe('{\n "tools": []\n}\n'); }); + it("sanitizes server-supplied terminal escapes in text mode", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "tools/call", + result: { + content: [{ type: "text", text: "\u001b]52;c;c3RvbGVu\u0007hi" }], + }, + }, + ); + expect(stdout).not.toContain("\u001b"); + expect(stdout).not.toContain("\u0007"); + expect(stdout).toContain("\u241b]52;c;c3RvbGVu\u2407hi"); + }); + + it("leaves json output verbatim (JSON escaping already protects it)", async () => { + await writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { content: [{ type: "text", text: "\u001bhi" }] }, + }, + ); + expect(JSON.parse(stdout)).toEqual({ + content: [{ type: "text", text: "\u001bhi" }], + }); + expect(stdout).toContain("\\u001bhi"); + }); + + it("sanitizes the ndjson stderr summary line", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "ndjson", + variant: "skill-verify", + lines: [], + summary: "done \u001b[2J", + }, + ); + expect(stderr).toContain("done \u241b[2J"); + expect(stderr).not.toContain("\u001b"); + }); + it("ignores auto-collected appInfo on tools/call json", async () => { await writeSessionOutput( { format: "json" }, diff --git a/clients/mcpi/__tests__/sanitize.test.ts b/clients/mcpi/__tests__/sanitize.test.ts new file mode 100644 index 0000000000..d01d50c383 --- /dev/null +++ b/clients/mcpi/__tests__/sanitize.test.ts @@ -0,0 +1,76 @@ +/** + * Terminal-escape sanitization (security). Server-controlled strings must + * never reach the terminal as raw control bytes — see src/session/sanitize.ts + * for the threat catalogue (OSC 52 clipboard writes, title spoofing, CSI + * rewriting, OSC 8 hyperlink breakout). + */ +import { describe, expect, it } from "vitest"; +import { sanitizeDeep, sanitizeText } from "../src/session/sanitize.js"; + +describe("sanitizeText", () => { + it("neutralizes an OSC 52 clipboard-write sequence", () => { + const attack = "\u001b]52;c;bWFsaWNpb3Vz\u0007done"; + const out = sanitizeText(attack); + expect(out).not.toContain("\u001b"); + expect(out).not.toContain("\u0007"); + expect(out).toBe("\u241b]52;c;bWFsaWNpb3Vz\u2407done"); + }); + + it("neutralizes OSC title spoofing and CSI cursor rewriting", () => { + expect(sanitizeText("\u001b]0;fake title\u0007")).toBe( + "\u241b]0;fake title\u2407", + ); + expect(sanitizeText("\u001b[2J\u001b[H")).toBe("\u241b[2J\u241b[H"); + }); + + it("neutralizes a BEL/ESC breakout inside a URI (OSC 8 wrapper safety)", () => { + const uri = "https://ok.test/\u0007\u001b]8;;https://evil.test\u0007"; + const out = sanitizeText(uri); + expect(out.includes("\u0007")).toBe(false); + expect(out.includes("\u001b")).toBe(false); + }); + + it("replaces C1 controls (8-bit CSI/OSC) with visible text", () => { + expect(sanitizeText("\u009b31mred")).toBe("\\u{9b}31mred"); + expect(sanitizeText("\u009d0;t\u009c")).toBe("\\u{9d}0;t\\u{9c}"); + }); + + it("replaces DEL and CR but preserves newline and tab", () => { + expect(sanitizeText("a\u007fb\rc")).toBe("a\u2421b\u240dc"); + expect(sanitizeText("line1\nline2\tend")).toBe("line1\nline2\tend"); + }); + + it("leaves ordinary text (including non-ASCII) untouched", () => { + const s = "hello — ünïcode ✅ 日本語"; + expect(sanitizeText(s)).toBe(s); + }); +}); + +describe("sanitizeDeep", () => { + it("sanitizes nested string values, array items, and object keys", () => { + const input = { + name: "tool\u001b[1m", + items: ["ok", "bad\u0007"], + nested: { "\u001bkey": { deep: "\u009btext" } }, + }; + expect(sanitizeDeep(input)).toEqual({ + name: "tool\u241b[1m", + items: ["ok", "bad\u2407"], + nested: { "\u241bkey": { deep: "\\u{9b}text" } }, + }); + }); + + it("passes non-string primitives and null through unchanged", () => { + expect(sanitizeDeep(42)).toBe(42); + expect(sanitizeDeep(true)).toBe(true); + expect(sanitizeDeep(null)).toBe(null); + expect(sanitizeDeep(undefined)).toBe(undefined); + }); + + it("does not mutate the input object", () => { + const input = { text: "esc\u001b" }; + const out = sanitizeDeep(input); + expect(input.text).toBe("esc\u001b"); + expect(out.text).toBe("esc\u241b"); + }); +}); diff --git a/clients/mcpi/src/daemon/auth.ts b/clients/mcpi/src/daemon/auth.ts index 68996ef556..12f38a8876 100644 --- a/clients/mcpi/src/daemon/auth.ts +++ b/clients/mcpi/src/daemon/auth.ts @@ -1,6 +1,26 @@ -import { timingSafeEqual } from "node:crypto"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import * as fs from "node:fs"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import { DAEMON_TOKEN_ENV } from "./paths.js"; +import { DAEMON_TOKEN_ENV, getDaemonTokenPath } from "./paths.js"; + +/** Fresh random IPC token for a daemon whose environment didn't supply one. */ +export function generateDaemonToken(): string { + return randomBytes(32).toString("hex"); +} + +/** + * Read the token a running daemon published to `daemon.token` (see + * {@link getDaemonTokenPath}). Undefined when missing/unreadable — the + * request will then fail authentication with a clear error. + */ +export function readDaemonTokenFile(dir?: string): string | undefined { + try { + const token = fs.readFileSync(getDaemonTokenPath(dir), "utf8").trim(); + return token || undefined; + } catch { + return undefined; + } +} /** * Read the IPC token from the environment (parent client or daemon child). diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index c83f6e804f..0812b58ed5 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import * as net from "node:net"; +import * as path from "node:path"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import { getDaemonTokenFromEnv } from "./auth.js"; +import { getDaemonTokenFromEnv, readDaemonTokenFile } from "./auth.js"; import { encodeRequest } from "./framing.js"; import { getDaemonSocketPath } from "./paths.js"; +import { sanitizeText } from "../session/sanitize.js"; import type { DaemonOp, DaemonRequest, @@ -48,7 +50,12 @@ export async function callDaemon( const socketPath = options.socketPath ?? getDaemonSocketPath(); const timeoutMs = options.timeoutMs ?? 60_000; const id = randomUUID(); - const token = options.token ?? getDaemonTokenFromEnv(); + // Env token wins (private mode / spawner); otherwise read the token the + // daemon published next to its socket (see getDaemonTokenPath). + const token = + options.token ?? + getDaemonTokenFromEnv() ?? + readDaemonTokenFile(path.dirname(socketPath)); const request: DaemonRequest = { id, op, params }; if (token !== undefined) request.token = token; @@ -148,7 +155,9 @@ export async function callDaemon( fail( new CliExitCodeError( response.error.exitCode ?? EXIT_CODES.USAGE, - response.error.message, + // Daemon error text can embed server-supplied strings; sanitize + // before it reaches a terminal via the shared error handler. + sanitizeText(response.error.message), { code: response.error.code }, ), ); diff --git a/clients/mcpi/src/daemon/ensure.ts b/clients/mcpi/src/daemon/ensure.ts index 69f6b435e3..ea9c08886e 100644 --- a/clients/mcpi/src/daemon/ensure.ts +++ b/clients/mcpi/src/daemon/ensure.ts @@ -4,13 +4,19 @@ import * as net from "node:net"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import { getDaemonTokenFromEnv } from "./auth.js"; +import { + generateDaemonToken, + getDaemonTokenFromEnv, + readDaemonTokenFile, +} from "./auth.js"; import { callDaemon } from "./client.js"; import { DAEMON_DIR_ENV, DAEMON_TOKEN_ENV, + assertSocketPathWithinLimit, ensureDaemonDir, getDaemonDir, + getDaemonLogPath, getDaemonSocketPath, } from "./paths.js"; @@ -70,8 +76,10 @@ async function isDaemonReachable(socketPath: string): Promise { async function waitForDaemon( socketPath: string, token: string | undefined, + logPath: string, + timeoutMs: number = READY_TIMEOUT_MS, ): Promise { - const deadline = Date.now() + READY_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isDaemonReachable(socketPath)) { try { @@ -83,65 +91,99 @@ async function waitForDaemon( } await new Promise((r) => setTimeout(r, READY_POLL_MS)); } - /* v8 ignore next 5 -- requires a stuck spawn */ + const logTail = readLogTail(logPath); throw new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Timed out waiting for session daemon at ${socketPath}`, + `Timed out waiting for session daemon at ${socketPath}` + + (logTail ? `\nDaemon log (${logPath}):\n${logTail}` : ""), { code: "daemon_start_timeout" }, ); } +/** Last few lines of the daemon's stderr log — the only trace of a spawn + * that died before binding its socket. Best-effort. Exported for tests. */ +export function readLogTail(logPath: string, maxLines = 10): string { + try { + const text = fs.readFileSync(logPath, "utf8"); + return text.trimEnd().split("\n").slice(-maxLines).join("\n"); + } catch { + return ""; + } +} + /** * Ensure a session daemon is running for the current {@link getDaemonDir}. * Auto-spawns a detached Node process when the socket is not reachable. * * When `MCP_INSPECTOR_DAEMON_TOKEN` is set (private mode), the child inherits - * that token and every IPC call must present it. + * that token; otherwise a fresh token is generated for the child. Either way + * every IPC call must present it (clients that didn't spawn the daemon read + * it from the published `daemon.token` file). */ export async function ensureDaemon(options?: { dir?: string; daemonScript?: string; token?: string; + /** Startup wait override (tests exercise the timeout path). */ + readyTimeoutMs?: number; }): Promise<{ socketPath: string; spawned: boolean }> { const dir = options?.dir ?? getDaemonDir(); - const token = options?.token ?? getDaemonTokenFromEnv(); + let token = options?.token ?? getDaemonTokenFromEnv(); ensureDaemonDir(dir); const socketPath = getDaemonSocketPath(dir); + // Fail here with an actionable error rather than letting the daemon's + // listen() die over sun_path limits with only a generic start timeout. + assertSocketPathWithinLimit(socketPath); if (await isDaemonReachable(socketPath)) { - try { - await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); - return { socketPath, spawned: false }; - } catch { - // stale socket — fall through to spawn - try { - fs.unlinkSync(socketPath); - } catch { - // ignore - } - } + // Something accepted the connection, so a live daemon owns this socket. + // Any ping failure here (daemon_auth_failed, timeout, protocol error) + // must fail loudly: unlinking and respawning would let a caller with the + // wrong token (or none) silently replace a live private daemon and + // orphan its sessions. Only a socket nothing is listening on — the + // unreachable path below — is stale, and the spawned daemon itself + // removes it after a connect probe (removeStaleDaemonSocket). + token ??= readDaemonTokenFile(dir); + await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + return { socketPath, spawned: false }; } + // Every daemon requires a token; generate one for the child when the + // caller/environment didn't supply one. The daemon republishes it to + // daemon.token (0600) so unrelated clients can still connect. + token ??= generateDaemonToken(); const script = options?.daemonScript ?? resolveDaemonScriptPath(); const childEnv: NodeJS.ProcessEnv = { ...process.env, // Pin the socket directory explicitly so parent and child agree even when // MCP_STORAGE_DIR is unset (default ~/.mcp-inspector). [DAEMON_DIR_ENV]: dir, + [DAEMON_TOKEN_ENV]: token, }; - if (token !== undefined) { - childEnv[DAEMON_TOKEN_ENV] = token; - } else { - delete childEnv[DAEMON_TOKEN_ENV]; + + // Detached + stdio "ignore" made every startup failure invisible. Capture + // stderr in a 0600 log the start-timeout error can quote. + const logPath = getDaemonLogPath(dir); + let stderrTarget: number | "ignore" = "ignore"; + try { + stderrTarget = fs.openSync(logPath, "a", 0o600); + /* v8 ignore next 3 -- log capture is best-effort; openSync on a freshly + ensured 0700 dir cannot be made to fail portably in tests. */ + } catch { + // The daemon still runs without a log. } const child = spawn(process.execPath, [script], { detached: true, - stdio: "ignore", + stdio: ["ignore", "ignore", stderrTarget], env: childEnv, }); child.unref(); + /* v8 ignore next -- "ignore" only when the best-effort openSync failed */ + if (typeof stderrTarget === "number") { + fs.closeSync(stderrTarget); + } - await waitForDaemon(socketPath, token); + await waitForDaemon(socketPath, token, logPath, options?.readyTimeoutMs); return { socketPath, spawned: true }; } diff --git a/clients/mcpi/src/daemon/index.ts b/clients/mcpi/src/daemon/index.ts index d7526945bb..7c0efe6856 100644 --- a/clients/mcpi/src/daemon/index.ts +++ b/clients/mcpi/src/daemon/index.ts @@ -1,6 +1,8 @@ export { assertDaemonToken, + generateDaemonToken, getDaemonTokenFromEnv, + readDaemonTokenFile, tokensEqual, } from "./auth.js"; export { callDaemon } from "./client.js"; @@ -8,14 +10,16 @@ export { streamDaemon } from "./stream-client.js"; export { ensureDaemon, resolveDaemonScriptPath } from "./ensure.js"; export { encodeRequest, encodeResponse, parseRequestLine } from "./framing.js"; export { + assertSocketPathWithinLimit, createPrivateDaemonDir, DAEMON_DIR_ENV, DAEMON_TOKEN_ENV, ensureDaemonDir, getDaemonDir, getDaemonLockPath, + getDaemonLogPath, getDaemonSocketPath, - getInspectorHome, + getDaemonTokenPath, } from "./paths.js"; export type { ConnectParams, diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts index 92c5bfa3c4..650fb835d7 100644 --- a/clients/mcpi/src/daemon/ipc-glue.ts +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -41,6 +41,14 @@ export type HandleRequest = ( elicitation: ElicitationChannel, ) => Promise; +/** + * Upper bound on a single NDJSON request line. A client that streams an + * unterminated line would otherwise grow readline's buffer without limit — + * a trivial local DoS on the daemon. 1 MiB is far beyond any legitimate + * request (tool args included) while staying cheap to buffer. + */ +export const MAX_REQUEST_LINE_BYTES = 1024 * 1024; + /** * Per-connection {@link ElicitationChannel}. Writes an elicitation-request * frame straight onto the socket (ahead of the eventual `DaemonResponse`) and @@ -105,6 +113,19 @@ export function acceptDaemonConnection( socket: net.Socket, handle: HandleRequest, ): void { + // Enforce the line cap below readline: track bytes since the last newline + // and drop the connection once a single line exceeds the limit. + let bytesSinceNewline = 0; + socket.on("data", (chunk: Buffer) => { + const idx = chunk.lastIndexOf(0x0a); + bytesSinceNewline = + idx === -1 ? bytesSinceNewline + chunk.length : chunk.length - idx - 1; + if (bytesSinceNewline > MAX_REQUEST_LINE_BYTES) { + // No error argument: nothing useful can be written back on a socket + // that's mid-way through an oversized line; just drop it. + socket.destroy(); + } + }); const rl = createInterface({ input: socket, crlfDelay: Infinity }); const elicitationChannel = new ConnectionElicitationChannel(socket); rl.on("line", (line) => { diff --git a/clients/mcpi/src/daemon/paths.ts b/clients/mcpi/src/daemon/paths.ts index d850b56af3..02ab25aefe 100644 --- a/clients/mcpi/src/daemon/paths.ts +++ b/clients/mcpi/src/daemon/paths.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -7,9 +7,9 @@ import * as path from "node:path"; export const DAEMON_DIR_ENV = "MCP_INSPECTOR_DAEMON_DIR"; /** - * Env: IPC bearer token for private daemons. When set in the daemon process, - * every request must present the same value. When unset, the daemon is shared - * (same-UID filesystem trust only). + * Env: IPC bearer token. Every daemon requires one: set it explicitly for + * private mode, or leave it unset and the daemon generates one at startup + * and publishes it to `daemon.token` (see {@link getDaemonTokenPath}). */ export const DAEMON_TOKEN_ENV = "MCP_INSPECTOR_DAEMON_TOKEN"; @@ -30,22 +30,27 @@ export function getDaemonDir(): string { return path.join(home, ".mcp-inspector"); } -/** `~/.mcp-inspector` (or HOME-equivalent), ignoring daemon-dir overrides. */ -export function getInspectorHome(): string { - /* v8 ignore next 2 -- USERPROFILE is the Windows fallback; CI/darwin use HOME. */ - const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); - return path.join(home, ".mcp-inspector"); -} - /** - * Create a new private daemon directory under `~/.mcp-inspector/private//` - * (mode `0700`). Does not start the daemon. + * Create a new private daemon directory (mode `0700`). Does not start the + * daemon. + * + * Lives under `$TMPDIR/mcpi-//`, not `~/.mcp-inspector`: `sun_path` + * caps Unix socket paths at 104 bytes on macOS (108 on Linux), and the tmp + * dir is short on every platform (macOS's per-user `/var/folders/...` is the + * long case, and even that fits with the 8-char id). The parent + * `mcpi-` dir is also created 0700 so the layout never depends on the + * platform's default tmp permissions. */ export function createPrivateDaemonDir(): string { - const id = randomUUID(); - const dir = path.join(getInspectorHome(), "private", id); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + /* v8 ignore next 2 -- getuid is missing only on Windows */ + const uid = typeof process.getuid === "function" ? process.getuid() : "u"; + const root = path.join(os.tmpdir(), `mcpi-${uid}`); + fs.mkdirSync(root, { recursive: true, mode: 0o700 }); + const id = randomBytes(4).toString("hex"); + const dir = path.join(root, id); + fs.mkdirSync(dir, { mode: 0o700 }); try { + fs.chmodSync(root, 0o700); fs.chmodSync(dir, 0o700); } catch { // best-effort on platforms that ignore mode @@ -61,7 +66,47 @@ export function getDaemonLockPath(dir: string = getDaemonDir()): string { return path.join(dir, "daemon.lock"); } -/** Ensure the daemon directory exists before binding the socket. */ +/** + * IPC token published by a running daemon (0600, inside the 0700 daemon + * dir). Written on start, removed on shutdown. Lets clients that didn't + * spawn the daemon (and so have no `MCP_INSPECTOR_DAEMON_TOKEN` in their + * environment) authenticate: filesystem permissions on the file are the + * trust boundary, which is exactly the same-user boundary the socket has — + * but requests now always carry a token, so there is no unauthenticated + * request path at all. + */ +export function getDaemonTokenPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.token"); +} + +/** Daemon stderr log (0600) — the only visibility into a detached daemon + * that died during startup. */ +export function getDaemonLogPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.log"); +} + +/** + * `sun_path` limit for Unix sockets: 104 bytes on macOS/BSD, 108 on Linux + * (both including the trailing NUL). `listen()` fails opaquely above it — + * historically the daemon then died silently and the client reported only a + * generic start timeout. Validate up front with an actionable error instead. + */ +export function assertSocketPathWithinLimit(socketPath: string): void { + /* v8 ignore next -- one arm per platform; CI runs each on its own OS */ + const limit = process.platform === "linux" ? 107 : 103; + const bytes = Buffer.byteLength(socketPath); + if (bytes > limit) { + throw new Error( + `Session daemon socket path is too long for this platform ` + + `(${bytes} bytes > ${limit}): ${socketPath}. ` + + `Point MCP_INSPECTOR_DAEMON_DIR (or MCP_STORAGE_DIR) at a shorter directory.`, + ); + } +} + +/** Ensure the daemon directory exists before binding the socket. + * Created 0700: the socket lives inside, so its own mode never has to be + * the enforcement boundary (BSDs are inconsistent about socket modes). */ export function ensureDaemonDir(dir: string = getDaemonDir()): void { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } diff --git a/clients/mcpi/src/daemon/run.ts b/clients/mcpi/src/daemon/run.ts index 4b28cd1b24..1f7af08feb 100644 --- a/clients/mcpi/src/daemon/run.ts +++ b/clients/mcpi/src/daemon/run.ts @@ -4,15 +4,28 @@ * Optional foreground `mcpi daemon run` is not shipped yet (see v2_cli_v2.md). */ import { DaemonServer } from "./server.js"; +import { generateDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; +import { ensureDaemonDir } from "./paths.js"; async function main(): Promise { const server = new DaemonServer({ + // No tokenless daemons: when the spawner didn't hand one down via + // MCP_INSPECTOR_DAEMON_TOKEN, generate one. start() publishes it to + // daemon.token (0600) for clients to read. + requiredToken: getDaemonTokenFromEnv() ?? generateDaemonToken(), onShutdown: () => { // Allow natural exit once the server closes and idle work finishes. process.exitCode = 0; }, }); + // Never keep the cwd of whichever mcpi invocation happened to spawn this + // daemon: connects would resolve relative stdio paths against it (and pin + // the directory against unmounting). The front end always sends an + // explicit cwd for stdio servers, so the daemon's own cwd is inert. + ensureDaemonDir(server.dir); + process.chdir(server.dir); + const shutdown = () => { void server.stop("signal").then(() => process.exit(0)); }; diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts index de03d87fa8..4b5898e8b8 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/mcpi/src/daemon/server.ts @@ -16,10 +16,12 @@ import { import { wireElicitationBridge } from "./elicitation-bridge.js"; import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; import { + assertSocketPathWithinLimit, ensureDaemonDir, getDaemonDir, getDaemonLockPath, getDaemonSocketPath, + getDaemonTokenPath, } from "./paths.js"; import type { ConnectParams, @@ -93,35 +95,59 @@ export class DaemonServer { async start(): Promise { ensureDaemonDir(this.dir); - await removeStaleDaemonSocket(this.socketPath); - this.writeLock(); + assertSocketPathWithinLimit(this.socketPath); + this.acquireLock(); + try { + await removeStaleDaemonSocket(this.socketPath); - this.server = net.createServer((socket) => { - acceptDaemonConnection(socket, (req, elicitation) => - this.handleOutcome(req, elicitation), - ); - }); + // Publish the IPC token (0600, inside the 0700 daemon dir) before the + // socket exists, so a client can never connect without being able to + // read the token it needs. See getDaemonTokenPath. + if (this.requiredToken !== undefined) { + const tokenPath = getDaemonTokenPath(this.dir); + fs.writeFileSync(tokenPath, this.requiredToken + "\n", { + mode: 0o600, + }); + try { + fs.chmodSync(tokenPath, 0o600); + } catch { + // Unsupported on some platforms. + } + } - await new Promise((resolve, reject) => { - this.server!.once("error", reject); - this.server!.listen(this.socketPath, () => { - this.server!.off("error", reject); - resolve(); + this.server = net.createServer((socket) => { + acceptDaemonConnection(socket, (req, elicitation) => + this.handleOutcome(req, elicitation), + ); }); - }); - // Restrict socket + lock to the creating user. Private mode also requires - // an IPC token (see specification/v2_cli_v2.md §5.3). - try { - fs.chmodSync(this.socketPath, 0o600); - fs.chmodSync(this.lockPath, 0o600); - } catch { - // Unsupported on some platforms (e.g. Windows named pipes). - } + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(this.socketPath, () => { + this.server!.off("error", reject); + resolve(); + }); + }); - // Session-less spawn (e.g. ensureDaemon from tools/list with no sessions) - // must still self-reap — idle was previously only armed after disconnect. - this.registry.armIdleTimerIfEmpty(); + // Restrict socket + lock to the creating user. Private mode also requires + // an IPC token (see specification/v2_cli_v2.md §5.3). + try { + fs.chmodSync(this.socketPath, 0o600); + fs.chmodSync(this.lockPath, 0o600); + } catch { + // Unsupported on some platforms (e.g. Windows named pipes). + } + + // Session-less spawn (e.g. ensureDaemon from tools/list with no sessions) + // must still self-reap — idle was previously only armed after disconnect. + this.registry.armIdleTimerIfEmpty(); + } catch (error) { + // Never leave a lock we own but no daemon behind it. The socket is only + // unlinked by removeStaleDaemonSocket after a dead connect probe, so a + // live daemon's socket is never touched here. + this.releaseLock(); + throw error; + } } async stop(reason: "idle" | "stop" | "signal" = "stop"): Promise { @@ -398,8 +424,58 @@ export class DaemonServer { }; } - private writeLock(): void { - fs.writeFileSync(this.lockPath, `${process.pid}\n`, { flag: "w" }); + /** + * `daemon.lock` is a real lock, not bookkeeping: `O_EXCL`-create it with + * our pid, and refuse to start while another *live* daemon holds it. A + * lock left by a dead pid is reclaimed (one retry). This closes the race + * where two starting daemons both probe a dead socket, both unlink, and + * the loser's unlink removes the winner's freshly-bound socket. + */ + private acquireLock(): void { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fd = fs.openSync(this.lockPath, "wx", 0o600); + fs.writeSync(fd, `${process.pid}\n`); + fs.closeSync(fd); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const holder = this.readLockPid(); + if (holder !== undefined && isPidAlive(holder)) { + throw new Error( + `Session daemon lock ${this.lockPath} is held by running pid ${holder}. ` + + `Use \`mcpi daemon/stop\`, or remove the file if that pid is not an mcpi daemon.`, + { cause: error }, + ); + } + try { + fs.unlinkSync(this.lockPath); + } catch { + // lost a removal race; the retry's O_EXCL create decides + } + } + } + throw new Error(`Could not acquire session daemon lock ${this.lockPath}`); + } + + private readLockPid(): number | undefined { + try { + const pid = Number.parseInt( + fs.readFileSync(this.lockPath, "utf8").trim(), + 10, + ); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; + } catch { + return undefined; + } + } + + private releaseLock(): void { + try { + fs.unlinkSync(this.lockPath); + } catch { + // absent is fine + } } private removeLockAndSocket(): void { @@ -409,10 +485,21 @@ export class DaemonServer { // absent is fine } try { - fs.unlinkSync(this.lockPath); + fs.unlinkSync(getDaemonTokenPath(this.dir)); } catch { // absent is fine } + this.releaseLock(); + } +} + +/** `kill(pid, 0)` liveness probe; EPERM means alive but not ours. */ +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; } } diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts index 7a2419cd37..2470013458 100644 --- a/clients/mcpi/src/daemon/stream-client.ts +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -6,8 +6,9 @@ */ import { randomUUID } from "node:crypto"; import * as net from "node:net"; +import * as path from "node:path"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import { getDaemonTokenFromEnv } from "./auth.js"; +import { getDaemonTokenFromEnv, readDaemonTokenFile } from "./auth.js"; import { encodeRequest } from "./framing.js"; import { getDaemonSocketPath } from "./paths.js"; import type { @@ -16,6 +17,7 @@ import type { DaemonStreamFrame, } from "./protocol.js"; import type { DaemonClientOptions } from "./client.js"; +import { sanitizeText } from "../session/sanitize.js"; export type StreamDaemonOptions = DaemonClientOptions & { onData: (data: unknown) => void; @@ -34,7 +36,10 @@ export async function streamDaemon( const socketPath = options.socketPath ?? getDaemonSocketPath(); const timeoutMs = options.timeoutMs ?? 60_000; const id = randomUUID(); - const token = options.token ?? getDaemonTokenFromEnv(); + const token = + options.token ?? + getDaemonTokenFromEnv() ?? + readDaemonTokenFile(path.dirname(socketPath)); const request: DaemonRequest = { id, op: "stream", params }; if (token !== undefined) request.token = token; @@ -90,7 +95,7 @@ export async function streamDaemon( fail( new CliExitCodeError( response.error.exitCode ?? EXIT_CODES.USAGE, - response.error.message, + sanitizeText(response.error.message), { code: response.error.code }, ), ); diff --git a/clients/mcpi/src/session/elicitation-prompt.ts b/clients/mcpi/src/session/elicitation-prompt.ts index 28c1178038..72b35d042c 100644 --- a/clients/mcpi/src/session/elicitation-prompt.ts +++ b/clients/mcpi/src/session/elicitation-prompt.ts @@ -21,6 +21,7 @@ import type { } from "../daemon/protocol.js"; import { parseFormSchema } from "./form-schema.js"; import { promptForm, watchForClose } from "./form-prompt.js"; +import { sanitizeDeep, sanitizeText } from "./sanitize.js"; export type PromptElicitationOpts = { /** @@ -69,9 +70,14 @@ export async function promptElicitation( opts: PromptElicitationOpts, ): Promise { const { style } = opts; + // Server-controlled display strings must not reach the terminal raw + // (escape injection — see sanitize.ts). Protocol ids on `frame` stay + // untouched so responses still correlate. + const message = sanitizeText(frame.message); + const url = frame.url === undefined ? undefined : sanitizeText(frame.url); if (frame.mode === "form") { - const fields = parseFormSchema(frame.requestedSchema); + const fields = parseFormSchema(sanitizeDeep(frame.requestedSchema)); if (!fields) { // Schema outside the spec's restricted primitive-field shape — // shouldn't happen from a well-behaved server; decline clearly rather @@ -80,7 +86,7 @@ export async function promptElicitation( style.yellow( "This server's form request uses a schema mcpi doesn't support " + "— declining.\n", - ) + ` ${frame.message}\n`, + ) + ` ${message}\n`, ); return declineResponse(frame); } @@ -90,7 +96,7 @@ export async function promptElicitation( style.yellow( "This server is asking for form input, which isn't supported " + "with --format json — declining.\n", - ) + ` ${frame.message}\n`, + ) + ` ${message}\n`, ); return declineResponse(frame); } @@ -100,7 +106,7 @@ export async function promptElicitation( output: process.stderr, }); try { - const outcome = await promptForm(rl, frame.message, fields, style); + const outcome = await promptForm(rl, message, fields, style); if (outcome.action === "accept") { return { id: frame.id, @@ -125,8 +131,8 @@ export async function promptElicitation( "This server is asking for input via a URL (elicitation), which " + "isn't supported with --format json — cancelling.\n", ) + - ` ${frame.message}\n` + - (frame.url ? ` ${frame.url}\n` : ""), + ` ${message}\n` + + (url ? ` ${url}\n` : ""), ); return cancelResponse(frame); } @@ -134,10 +140,10 @@ export async function promptElicitation( process.stderr.write( "\n" + style.bold("Action required: ") + - frame.message + + message + "\n" + " " + - style.link(frame.url ?? "", frame.url) + + style.link(url ?? "", url) + "\n\n", ); diff --git a/clients/mcpi/src/session/ema.ts b/clients/mcpi/src/session/ema.ts index d922b80420..611d037346 100644 --- a/clients/mcpi/src/session/ema.ts +++ b/clients/mcpi/src/session/ema.ts @@ -224,6 +224,8 @@ export async function emaLogin(options?: { }, redirectUrlProvider, callbackListen: callbackUrlConfig, + // mcpi is a plain CLI (no Ink); own Ctrl-C during the IdP wait. + handleSignals: true, }); resetNodeOAuthStorageCache(); diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts index 3f5e6e7c26..c1376b82f0 100644 --- a/clients/mcpi/src/session/format-session.ts +++ b/clients/mcpi/src/session/format-session.ts @@ -19,6 +19,7 @@ import { formatSkillVerifyListHuman, formatStreamEventHuman, } from "./format-human.js"; +import { sanitizeDeep, sanitizeText } from "./sanitize.js"; import { PLAIN, type Style } from "@inspector/cli/style.js"; type JsonObject = Record; @@ -118,7 +119,12 @@ export async function writeSessionOutput( return; } - await awaitableLog(humanPayload(payload, style) + "\n"); + // Server-controlled strings must never reach the terminal raw (escape + // injection: OSC 52 clipboard writes, title spoofing, output rewriting). + // Sanitize the whole payload before human formatting; the formatter's own + // ANSI styling is applied afterwards and stays intact. JSON output above + // is already safe — JSON.stringify escapes control characters. + await awaitableLog(humanPayload(sanitizeDeep(payload), style) + "\n"); await writeNdjsonSummary(payload); applyExitCodes(payload); } @@ -132,7 +138,9 @@ export async function writeSessionOutput( */ async function writeNdjsonSummary(payload: SessionWriteKind): Promise { if (payload.kind === "ndjson" && payload.summary) { - await awaitableError(`${payload.summary}\n`); + // Human-facing stderr line in both formats; may embed server-derived + // names, so sanitize (see sanitize.ts). + await awaitableError(`${sanitizeText(payload.summary)}\n`); } } diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index d05638b8fd..be3fdfe1f4 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -353,7 +353,10 @@ function registerConnect(program: CommandType): void { target: adHoc ? (rest.length > 0 ? rest : undefined) : undefined, transport: cmdOpts.transport as "sse" | "http" | "stdio" | undefined, serverUrl: cmdOpts.serverUrl as string | undefined, - cwd: cmdOpts.cwd as string | undefined, + // Resolve --cwd against the CALLER's working directory. The daemon + // that spawns the stdio server inherits an unrelated cwd (see + // daemon/run.ts), so a relative --cwd must be pinned here. + cwd: cmdOpts.cwd ? path.resolve(cmdOpts.cwd as string) : undefined, env: cmdOpts.e as Record | undefined, headers: cmdOpts.header as Record | undefined, }; @@ -364,7 +367,15 @@ function registerConnect(program: CommandType): void { const entries = await loadServerEntries(serverOptions); const selected = selectServerEntry(entries, selectName); - const serverConfig = selected.config; + let serverConfig = selected.config; + // A stdio config with no cwd would resolve relative commands and + // relative paths against the DAEMON's cwd — whichever directory the + // first mcpi invocation happened to run from. Pin it to the caller's + // cwd, which is what `mcpi connect node ./server.js` means to the user. + // A cwd configured in the catalog/config entry (or --cwd) still wins. + if (serverConfig.type === "stdio" && !serverConfig.cwd) { + serverConfig = { ...serverConfig, cwd: process.cwd() }; + } const serverSettings = withEmaOverride( withElicitOverride( withEraOverride( diff --git a/clients/mcpi/src/session/sanitize.ts b/clients/mcpi/src/session/sanitize.ts new file mode 100644 index 0000000000..a7ba752bbb --- /dev/null +++ b/clients/mcpi/src/session/sanitize.ts @@ -0,0 +1,51 @@ +/** + * Terminal-output sanitization for server-controlled text (security). + * + * Every string a server sends (tool results, descriptions, resource text, + * elicitation messages, URIs) reaches the user's terminal through the human + * formatter. Raw C0/C1 control bytes in that text are attacker-controlled + * terminal commands: OSC 52 writes the clipboard, OSC 0 spoofs the window + * title, CSI moves/erases earlier output, and a BEL/ESC inside a URI breaks + * out of an OSC 8 hyperlink wrapper. `--format json` is safe (JSON escapes + * them); this module makes `--format text` safe by replacing every control + * character except `\n` and `\t` with a visible stand-in before any styling + * (so the CLI's own ANSI styling, added afterwards, is unaffected). + * + * Replacements: C0 → Unicode Control Pictures (␀…␟, e.g. ESC → ␛), + * DEL → ␡, C1 (0x80–0x9F, includes 8-bit CSI/OSC) → ␡-style `\u{9b}` text. + */ + +const CONTROL_CHARS = + // C0 minus \t (0x09) and \n (0x0A), plus DEL and the C1 range. + // eslint-disable-next-line no-control-regex + /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g; + +function visibleControl(ch: string): string { + const code = ch.codePointAt(0)!; + if (code <= 0x1f) return String.fromCodePoint(0x2400 + code); + if (code === 0x7f) return "\u2421"; // ␡ + return `\\u{${code.toString(16)}}`; // C1: no control picture exists +} + +/** Replace terminal control characters (except `\n`/`\t`) with visible text. */ +export function sanitizeText(value: string): string { + return value.replace(CONTROL_CHARS, visibleControl); +} + +/** + * Deep-sanitize every string in a payload (values AND object keys) ahead of + * human formatting. Input is JSON-shaped data (daemon responses are + * JSON-parsed), so plain objects/arrays/primitives are the whole universe. + */ +export function sanitizeDeep(value: T): T { + if (typeof value === "string") return sanitizeText(value) as T; + if (Array.isArray(value)) return value.map((v) => sanitizeDeep(v)) as T; + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[sanitizeText(k)] = sanitizeDeep(v); + } + return out as T; + } + return value; +} diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index 1c53e4dc4f..b89a8b9880 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -539,6 +539,7 @@ describe("runRunnerInteractiveOAuth", () => { pathname: "/oauth/callback", }, createCallbackServer: () => mockServer, + handleSignals: true, }); // Give beginInteractiveAuthorization/authenticate a tick to register the @@ -573,6 +574,7 @@ describe("runRunnerInteractiveOAuth", () => { pathname: "/oauth/callback", }, createCallbackServer: () => mockServer, + handleSignals: true, }); await Promise.resolve(); @@ -584,4 +586,31 @@ describe("runRunnerInteractiveOAuth", () => { ); expect(process.listenerCount("SIGTERM")).toBe(0); }); + + it("installs no signal listeners unless handleSignals is set (TUI owns Ctrl-C via Ink)", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + const before = process.listenerCount("SIGINT"); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(process.listenerCount("SIGINT")).toBe(before); + + await simulateCallback(handlers.current); + await expect(promise).resolves.toEqual({ kind: "success" }); + }); }); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index 4adfbbbdb0..00e01bd8f5 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -40,6 +40,14 @@ export interface RunRunnerInteractiveOAuthOptions { onCallbackServer?: (server: OAuthCallbackServer) => void; /** Max wait for browser callback; defaults to {@link DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS}. */ callbackTimeoutMs?: number; + /** + * Install process-wide SIGINT/SIGTERM handlers for the length of the wait + * so Ctrl-C rejects the flow cleanly (server stopped, classifiable error) + * instead of hanging or hitting Node's default abrupt exit. Opt-in + * because it is process-global state: the TUI owns Ctrl-C through Ink and + * must not have it intercepted here. CLI/mcpi callers pass `true`. + */ + handleSignals?: boolean; } /** @@ -84,12 +92,15 @@ export async function runRunnerInteractiveOAuth( // with no cleanup. Reject cleanly instead so the server is stopped and the // caller gets a normal, classifiable error ("OAuth" in the message maps to // AUTH_REQUIRED — see clients/cli/src/error-handler.ts) rather than a raw - // process death. + // process death. Opt-in (see handleSignals) — never installed under the + // TUI, which owns Ctrl-C through Ink. const onSignal = (signal: NodeJS.Signals) => { flowReject(new Error(`OAuth authorization cancelled (${signal}).`)); }; - process.on("SIGINT", onSignal); - process.on("SIGTERM", onSignal); + if (options.handleSignals) { + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + } let timeoutId: ReturnType | undefined; @@ -167,8 +178,10 @@ export async function runRunnerInteractiveOAuth( return { kind: "success" }; } finally { - process.off("SIGINT", onSignal); - process.off("SIGTERM", onSignal); + if (options.handleSignals) { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + } if (timeoutId !== undefined) { clearTimeout(timeoutId); } diff --git a/skills/mcpi/SKILL.md b/skills/mcpi/SKILL.md index 79b34d9988..f553b5bf52 100644 --- a/skills/mcpi/SKILL.md +++ b/skills/mcpi/SKILL.md @@ -46,7 +46,9 @@ commands and flags. auth/list` / `mcpi auth/clear`); nothing extra is needed for authenticated HTTP servers beyond `connect` and completing the browser flow if prompted. - If a server asks a question mid-call (elicitation), mcpi prompts - interactively by default; running non-interactively (no TTY, scripted, or - `--format json`) auto-declines instead of hanging. Pass `--elicit off` on + interactively by default — including over a plain non-TTY stdin, so an + agent can relay the question and answer it. Only `--format json` (whose + stdout must stay a single machine-readable payload) auto-declines instead + of prompting. Pass `--elicit off` on `connect` if you want a well-behaved server to fall back to its own defaults instead. diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index b901707839..2be9f14e19 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -123,14 +123,14 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) | Context | Path | | --- | --- | -| Shared default | `~/.mcp-inspector/daemon.sock` (+ lock) | +| Shared default | `~/.mcp-inspector/daemon.sock` (+ `daemon.lock`, `daemon.token`, `daemon.log`) | | `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | | `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | -| Private | `~/.mcp-inspector/private//` from `mcpi private` | +| Private | `$TMPDIR/mcpi-//` (0700, short id — `sun_path` caps socket paths at 104 bytes on macOS) from `mcpi private` | | Mode | Trust | | --- | --- | -| **Shared (default)** | No token. Same-UID peer that can open the socket can drive sessions (intentional cross-terminal share). | +| **Shared (default)** | Auto-generated token, published to `daemon.token` (0600) in the daemon dir (0700). Same-UID peer that can read the dir can drive sessions (intentional cross-terminal share); there is no unauthenticated request path. | | **Private** | `eval "$(mcpi private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | #### Auth (session) @@ -166,9 +166,8 @@ Both are wired into root `validate` / `coverage`. | Item | Notes | | --- | --- | | **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpi` during `rpc`/`stream`. Connect-time only today. | -| **Daemon singleton / exclusive lock** | `daemon.lock` writes a PID but does not enforce exclusive spawn or stale-PID reclaim. Concurrent `ensureDaemon` can race. | | **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | -| **Per-socket request serialization** | Accept handler is unbounded per NDJSON line; safe while clients use one request per connection. | +| **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | | **Per-session RPC mutex** | Parallel `mcpi` processes against one session can interleave on one `InspectorClient`. | | **`streamDaemon` post-open errors** | Socket errors after the initial ok frame are treated as soft end. | | **Coverage gate for `ipc-glue` / `stream-client`** | Behavioral tests exist; files excluded until the race matrix is stably ≥90. | @@ -179,7 +178,7 @@ Both are wired into root `validate` / `coverage`. | **Session `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; session authorize uses defaults / env only. | | **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | | **Stream fan-out / `mcpi attach`** | One consumer per stream invocation today. | -| **Sampling CLI** | Still TUI/web. mcpi handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. | +| **Sampling CLI** | Still TUI/web. mcpi handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | | **Ephemeral no-`connect` shortcuts on `mcpi`** | Out of scope (keep two mental models). | | **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | | **Human `--full` schema dumps** | Optional formatter polish. | From e0e81cb32e35dfea2ab203deb884d2c9cb5678a4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 00:51:56 -0700 Subject: [PATCH 04/69] fix(mcpi): move runtime deps to the root manifest (review 2a) clients/mcpi declared root-owned runtime dependencies, re-creating the second copy the dependency-placement rule (#1896) exists to prevent, and the re-declaration was load-bearing: tsup auto-externalized from the client manifest, so the external list was incomplete. - clients/mcpi/package.json now declares no runtime dependencies (same steady state as clients/cli and clients/launcher); the 3,387-line lockfile shrinks to devDeps only. - clients/mcpi/tsup.config.ts names every root runtime dependency that core/ (or the bundled one-shot CLI source) reaches, mirroring clients/cli/tsup.config.ts; verify:bundle-externals passes against the built output. - A scoped override pins sucrase's nested commander to ^13: with no top-level commander declared, npm otherwise hoists sucrase's commander@4 into clients/mcpi/node_modules where it shadows the root commander@13 on the walk-up (helpCommand crash at startup). - AGENTS.md's "three lists" rule is now four (clients/{cli,mcpi,tui} tsup configs + web's runner config); the no-runtime-deps steady state names mcpi; sdk-watch's checklist string updated to match. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 +- clients/mcpi/package-lock.json | 2078 +++----------------------------- clients/mcpi/package.json | 19 +- clients/mcpi/tsup.config.ts | 17 +- scripts/sdk-watch.mjs | 2 +- 5 files changed, 161 insertions(+), 1961 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f04cd2b1c9..05efe21dd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,14 +93,14 @@ The reasoning behind each of these, and what breaks when it is ignored, is the `local-dev` skill. The rules themselves: - **Every runtime dependency `core/` imports is declared in the repo-root `package.json` and nowhere else.** That is the MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) and, since #2195, the rest of what `core/` reaches: `ajv`, `atomically`, `chokidar`, `hono`, `@napi-rs/keyring`, `pino`, `proper-lockfile`, `react`, `undici`, `zod`. So is anything reached only through root-owned code with no manifest of its own (`test-servers/src`, `core/`). The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one. -- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all three. -- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back. +- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all four. +- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli`, `clients/mcpi` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back. - **A package that moves to the root moves its `vitest.shared.mts` pin with it.** Left pointing at `/node_modules` a pin resolves to a directory that no longer exists — or, where a transitive copy happens to sit there (`chokidar` under `vite`, `react` as a peer of `react-dom` and `ink`), to the very duplicate the pin list exists to prevent. **`react` and `react-dom` are the deliberate exception** and stay pinned per client, so a client's renderer and the React it calls into come from one install; every other root-owned pin resolves from the repo root. - **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. - **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale. - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way). - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301). -- **A root-declared package that `core/` imports at runtime must also be named in all three bundler `external` lists** (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. +- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,mcpi,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. - **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`). - **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both. - **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading. diff --git a/clients/mcpi/package-lock.json b/clients/mcpi/package-lock.json index c78f268c9a..ba36e731ab 100644 --- a/clients/mcpi/package-lock.json +++ b/clients/mcpi/package-lock.json @@ -6,20 +6,6 @@ "": { "name": "@modelcontextprotocol/mcpi", "license": "MIT", - "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "@modelcontextprotocol/server-legacy": "2.0.0", - "@napi-rs/keyring": "^1.3.0", - "ajv": "8.18.0", - "atomically": "^2.1.1", - "commander": "^13.1.0", - "open": "^10.2.0", - "pino": "^9.14.0", - "undici": "8.9.0", - "zod": "4.4.3" - }, "bin": { "mcpi": "build/mcp-bin.js" }, @@ -509,295 +495,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", - "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, - "node_modules/@napi-rs/keyring": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", - "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/keyring-darwin-arm64": "1.3.0", - "@napi-rs/keyring-darwin-x64": "1.3.0", - "@napi-rs/keyring-freebsd-x64": "1.3.0", - "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", - "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", - "@napi-rs/keyring-linux-arm64-musl": "1.3.0", - "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", - "@napi-rs/keyring-linux-x64-gnu": "1.3.0", - "@napi-rs/keyring-linux-x64-musl": "1.3.0", - "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", - "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", - "@napi-rs/keyring-win32-x64-msvc": "1.3.0" - } - }, - "node_modules/@napi-rs/keyring-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", - "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-freebsd-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", - "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", - "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", - "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", - "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", - "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", - "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", - "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-arm64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", - "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-ia32-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", - "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-x64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", - "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -815,16 +512,10 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", - "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", "cpu": [ "arm" ], @@ -836,9 +527,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", - "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", "cpu": [ "arm64" ], @@ -850,9 +541,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", - "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", "cpu": [ "arm64" ], @@ -864,9 +555,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", - "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", "cpu": [ "x64" ], @@ -878,9 +569,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", - "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", "cpu": [ "arm64" ], @@ -892,9 +583,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", - "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", "cpu": [ "x64" ], @@ -906,9 +597,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", - "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", "cpu": [ "arm" ], @@ -920,9 +611,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", - "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", "cpu": [ "arm" ], @@ -934,9 +625,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", - "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", "cpu": [ "arm64" ], @@ -948,9 +639,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", - "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", "cpu": [ "arm64" ], @@ -962,9 +653,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", - "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", "cpu": [ "loong64" ], @@ -976,9 +667,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", - "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", "cpu": [ "loong64" ], @@ -990,9 +681,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", - "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", "cpu": [ "ppc64" ], @@ -1004,9 +695,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", - "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", "cpu": [ "ppc64" ], @@ -1018,9 +709,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", - "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", "cpu": [ "riscv64" ], @@ -1032,9 +723,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", - "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", "cpu": [ "riscv64" ], @@ -1046,9 +737,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", - "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", "cpu": [ "s390x" ], @@ -1060,9 +751,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", - "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", "cpu": [ "x64" ], @@ -1074,9 +765,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", - "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", "cpu": [ "x64" ], @@ -1088,9 +779,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", - "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", "cpu": [ "x64" ], @@ -1102,9 +793,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", - "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", "cpu": [ "arm64" ], @@ -1116,9 +807,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", - "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", "cpu": [ "arm64" ], @@ -1130,9 +821,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", - "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", "cpu": [ "ia32" ], @@ -1144,9 +835,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", - "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", "cpu": [ "x64" ], @@ -1158,9 +849,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", - "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", "cpu": [ "x64" ], @@ -1182,23 +873,6 @@ "@types/node": "*" } }, - "node_modules/@types/body-parser/node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/body-parser/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1209,23 +883,6 @@ "@types/node": "*" } }, - "node_modules/@types/connect/node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/connect/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1258,23 +915,6 @@ "@types/send": "*" } }, - "node_modules/@types/express-serve-static-core/node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/express-serve-static-core/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1282,6 +922,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1306,23 +956,6 @@ "@types/node": "*" } }, - "node_modules/@types/send/node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/send/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", @@ -1334,64 +967,17 @@ "@types/node": "*" } }, - "node_modules/@types/serve-static/node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/serve-static/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, "node_modules/any-promise": { @@ -1401,79 +987,6 @@ "dev": true, "license": "MIT" }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/atomically": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1490,15 +1003,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1509,37 +1013,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1560,6 +1033,7 @@ "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1582,84 +1056,11 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1673,120 +1074,6 @@ } } }, - "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1829,129 +1116,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1970,28 +1134,6 @@ } } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -2004,26 +1146,6 @@ "rollup": "^4.34.8" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2039,225 +1161,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2268,12 +1171,6 @@ "node": ">=10" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2314,70 +1211,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2395,6 +1228,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2406,140 +1240,17 @@ "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" + "thenify-all": "^1.0.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=0.10.0" } }, "node_modules/pathe": { @@ -2569,43 +1280,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -2616,15 +1290,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2680,88 +1345,6 @@ } } }, - "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2776,24 +1359,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2805,9 +1370,9 @@ } }, "node_modules/rollup": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", - "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2822,237 +1387,34 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.2", - "@rollup/rollup-android-arm64": "4.63.2", - "@rollup/rollup-darwin-arm64": "4.63.2", - "@rollup/rollup-darwin-x64": "4.63.2", - "@rollup/rollup-freebsd-arm64": "4.63.2", - "@rollup/rollup-freebsd-x64": "4.63.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", - "@rollup/rollup-linux-arm-musleabihf": "4.63.2", - "@rollup/rollup-linux-arm64-gnu": "4.63.2", - "@rollup/rollup-linux-arm64-musl": "4.63.2", - "@rollup/rollup-linux-loong64-gnu": "4.63.2", - "@rollup/rollup-linux-loong64-musl": "4.63.2", - "@rollup/rollup-linux-ppc64-gnu": "4.63.2", - "@rollup/rollup-linux-ppc64-musl": "4.63.2", - "@rollup/rollup-linux-riscv64-gnu": "4.63.2", - "@rollup/rollup-linux-riscv64-musl": "4.63.2", - "@rollup/rollup-linux-s390x-gnu": "4.63.2", - "@rollup/rollup-linux-x64-gnu": "4.63.2", - "@rollup/rollup-linux-x64-musl": "4.63.2", - "@rollup/rollup-openbsd-x64": "4.63.2", - "@rollup/rollup-openharmony-arm64": "4.63.2", - "@rollup/rollup-win32-arm64-msvc": "4.63.2", - "@rollup/rollup-win32-ia32-msvc": "4.63.2", - "@rollup/rollup-win32-x64-gnu": "4.63.2", - "@rollup/rollup-win32-x64-msvc": "4.63.2", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -3063,39 +1425,6 @@ "node": ">= 12" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", - "license": "MIT", - "dependencies": { - "stubborn-utils": "^1.0.1" - } - }, - "node_modules/stubborn-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", - "license": "MIT" - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3119,16 +1448,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3152,15 +1471,6 @@ "node": ">=0.8" } }, - "node_modules/thread-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", - "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -3185,15 +1495,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3264,39 +1565,6 @@ } } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -3304,84 +1572,12 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/when-exit": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/clients/mcpi/package.json b/clients/mcpi/package.json index 59043726c0..c680c90b4d 100644 --- a/clients/mcpi/package.json +++ b/clients/mcpi/package.json @@ -27,25 +27,14 @@ "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, - "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "@modelcontextprotocol/server-legacy": "2.0.0", - "@napi-rs/keyring": "^1.3.0", - "ajv": "8.18.0", - "atomically": "^2.1.1", - "commander": "^13.1.0", - "open": "^10.2.0", - "pino": "^9.14.0", - "undici": "8.9.0", - "zod": "4.4.3" - }, "devDependencies": { "@types/express": "^5.0.6", "tsup": "^8.5.0" }, "overrides": { - "@types/node": "^24.12.4" + "@types/node": "^24.12.4", + "sucrase": { + "commander": "^13.1.0" + } } } diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts index c3ee701765..cb239c7daa 100644 --- a/clients/mcpi/tsup.config.ts +++ b/clients/mcpi/tsup.config.ts @@ -22,16 +22,31 @@ export default defineConfig({ // Bundle core + one-shot CLI internals (handlers, error-handler, OAuth helpers). // Temporary reach-in until a dedicated shared package exists — see README. noExternal: [/^@inspector\/core/, /^@inspector\/cli/], + // Mirrors clients/cli/tsup.config.ts (which documents each entry's story): + // this client declares NO runtime dependencies (AGENTS.md dependency- + // placement rule), so tsup's nearest-manifest auto-externalization sees + // nothing — every root-declared runtime package `core/` (or the bundled + // one-shot CLI source) imports must be named here or esbuild inlines it, + // and inlining a CJS module into this ESM bundle leaves esbuild's + // `Dynamic require of "..." is not supported` shim (#2067). + // `npm run verify:bundle-externals` enforces this against the built output. external: [ + "undici", "@napi-rs/keyring", + "proper-lockfile", "@modelcontextprotocol/client", "@modelcontextprotocol/core", "@modelcontextprotocol/ext-apps", "commander", "pino", + "ajv", + "atomically", "open", + "zod", "yaml", - "proper-lockfile", + "chokidar", + "hono", + "react", ], esbuildOptions(options) { options.alias = { diff --git a/scripts/sdk-watch.mjs b/scripts/sdk-watch.mjs index a55240f322..da7a1a720a 100644 --- a/scripts/sdk-watch.mjs +++ b/scripts/sdk-watch.mjs @@ -529,7 +529,7 @@ export function buildIssueBody(state) { "### Upgrade checklist", "", ...manifestChecklist(rows, target), - "- [ ] Re-check the bundler `external` lists (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`) if the release adds or renames an entry point; `npm run verify:bundle-externals` enforces this against the built output.", + "- [ ] Re-check the bundler `external` lists (`clients/{cli,mcpi,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`) if the release adds or renames an entry point; `npm run verify:bundle-externals` enforces this against the built output.", "- [ ] `npm run format`, then `npm run local:gate`.", "", "An automated review of what actually changed upstream — and which parts of this app it touches — is posted as a comment below.", From 8e153cd566bd9cb0e953d4a1754799106ba33b28 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 01:01:00 -0700 Subject: [PATCH 05/69] test(mcpi): bring ipc-glue and stream-client into the coverage gate (review 2b) Remove the "hard-to-stabilize accept/stream races" coverage exclusions for src/daemon/ipc-glue.ts and src/daemon/stream-client.ts; only true bootstraps (src/mcp-bin.ts, src/daemon/run.ts) stay outside the gate. New __tests__/daemon-ipc-glue.test.ts exercises the per-connection wiring deterministically with an in-memory Duplex (no accept races): the elicitation channel round trip, non-answer lines, double-pending rejection, disconnect/destroyed-socket rejection, the mid-handle destroyed guard, and single-shot stream cleanup on socket error. daemon-stream.test.ts gains default socket-path/timeout + explicit token coverage and a post-end frame-ignore case. Writing those tests surfaced a real bug: readline re-emits socket errors on the interface, so a client RST would have crashed the daemon with an unhandled 'error' event. acceptDaemonConnection now attaches an rl error listener; the socket error handler keeps owning teardown. Both files clear >=90 on all four dimensions (ipc-glue 99/95/95/100, stream-client 96/92/93/97); mcpi suite 250/250. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- .../mcpi/__tests__/daemon-ipc-glue.test.ts | 233 ++++++++++++++++++ clients/mcpi/__tests__/daemon-stream.test.ts | 57 +++++ clients/mcpi/src/daemon/ipc-glue.ts | 7 +- clients/mcpi/src/daemon/stream-client.ts | 3 - clients/mcpi/vitest.config.ts | 7 +- 6 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 clients/mcpi/__tests__/daemon-ipc-glue.test.ts diff --git a/AGENTS.md b/AGENTS.md index 05efe21dd0..db9048bfbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -397,7 +397,7 @@ When asked to respond to a code review of a PR: The _procedure_ — where a given test file goes, which command runs it, how to diagnose a failing gate — is the `testing` skill. These are the rules. -- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes bootstraps + hard-to-stabilize accept/stream races from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API). +- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API). - **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise. - **In unit tests that expect error output, suppress it from the console.** - **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.** diff --git a/clients/mcpi/__tests__/daemon-ipc-glue.test.ts b/clients/mcpi/__tests__/daemon-ipc-glue.test.ts new file mode 100644 index 0000000000..ec9fdad51f --- /dev/null +++ b/clients/mcpi/__tests__/daemon-ipc-glue.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for `acceptDaemonConnection`'s per-connection wiring: the + * elicitation channel, destroyed-socket guards, and stream cleanup. A fake + * in-memory Duplex stands in for the net.Socket so every path is exercised + * deterministically (no accept/connect races). + */ +import { describe, it, expect } from "vitest"; +import { Duplex } from "node:stream"; +import type * as net from "node:net"; +import { + acceptDaemonConnection, + type ElicitationChannel, +} from "../src/daemon/ipc-glue.js"; +import type { + DaemonRequest, + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "../src/daemon/protocol.js"; + +class FakeSocket extends Duplex { + written: string[] = []; + override _read(): void {} + override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.written.push(String(chunk)); + callback(); + } + pushLine(line: string): void { + this.push(line + "\n"); + } + get all(): string { + return this.written.join(""); + } +} + +function accept( + handle: ( + request: DaemonRequest, + elicitation: ElicitationChannel, + ) => Promise<{ + response: { id: string; ok: true; result: unknown }; + startStream?: (writeData: (data: unknown) => void) => () => void; + }>, +): FakeSocket { + const socket = new FakeSocket(); + acceptDaemonConnection(socket as unknown as net.Socket, handle); + return socket; +} + +/** Await an event-driven condition (no fixed sleeps). */ +async function until(condition: () => boolean): Promise { + while (!condition()) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +const REQUEST = JSON.stringify({ id: "r1", op: "rpc", params: {} }); + +function elicitationRequest(id: string): ElicitationRequestFrame { + return { + id, + kind: "elicitation-request", + elicitationId: `elicit-${id}`, + mode: "form", + message: "pick one", + origin: "server-request", + }; +} + +function elicitationResponse(id: string): ElicitationResponseFrame { + return { + id, + kind: "elicitation-response", + elicitationId: `elicit-${id}`, + action: "accept", + content: {}, + }; +} + +describe("acceptDaemonConnection elicitation channel", () => { + it("pauses a call for an elicitation exchange and resumes on the answer", async () => { + const socket = accept(async (request, elicitation) => { + const answer = await elicitation.request(elicitationRequest("e1")); + return { + response: { id: request.id, ok: true, result: { action: answer } }, + }; + }); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"elicitation-request"')); + + socket.pushLine(JSON.stringify(elicitationResponse("e1"))); + await until(() => socket.all.includes('"ok":true')); + expect(socket.all).toContain('"action"'); + }); + + it("ignores non-answer lines while an exchange is pending", async () => { + const socket = accept(async (request, elicitation) => { + const answer = await elicitation.request(elicitationRequest("e2")); + return { response: { id: request.id, ok: true, result: answer } }; + }); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"elicitation-request"')); + + // None of these are elicitation answers; each falls through to the + // request parser and earns an invalid_request response. + socket.pushLine("not-json"); + socket.pushLine("null"); + socket.pushLine(JSON.stringify({ kind: "other" })); + await until(() => socket.all.split('"invalid_request"').length - 1 === 3); + + socket.pushLine(JSON.stringify(elicitationResponse("e2"))); + await until(() => socket.all.includes('"ok":true')); + }); + + it("rejects a second exchange while one is already pending", async () => { + let secondError: Error | undefined; + const socket = accept(async (request, elicitation) => { + const first = elicitation.request(elicitationRequest("e3")); + await elicitation + .request(elicitationRequest("e4")) + .catch((error: Error) => { + secondError = error; + }); + socket.pushLine(JSON.stringify(elicitationResponse("e3"))); + await first; + return { response: { id: request.id, ok: true, result: {} } }; + }); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"ok":true')); + expect(secondError?.message).toMatch(/already pending/); + }); + + it("rejects a pending exchange when the connection drops", async () => { + let rejection: Error | undefined; + const settled = { done: false }; + const socket = accept(async (request, elicitation) => { + elicitation.request(elicitationRequest("e5")).catch((error: Error) => { + rejection = error; + settled.done = true; + }); + return { response: { id: request.id, ok: true, result: {} } }; + }); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"elicitation-request"')); + socket.destroy(); + await until(() => settled.done); + expect(rejection?.message).toMatch(/Connection closed/); + }); + + it("rejects immediately when the socket is already destroyed", async () => { + let rejection: Error | undefined; + const settled = { done: false }; + let channel: ElicitationChannel | undefined; + const socket = accept(async (request, elicitation) => { + channel = elicitation; + return { response: { id: request.id, ok: true, result: {} } }; + }); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"ok":true')); + socket.destroy(); + await until(() => socket.destroyed); + await channel!.request(elicitationRequest("e6")).catch((error: Error) => { + rejection = error; + settled.done = true; + }); + expect(settled.done).toBe(true); + expect(rejection?.message).toMatch(/Connection closed/); + }); +}); + +describe("acceptDaemonConnection guards", () => { + it("drops the response when the socket dies mid-handle", async () => { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const handled = { done: false }; + const socket = accept(async (request) => { + await gate; + handled.done = true; + return { response: { id: request.id, ok: true, result: {} } }; + }); + + socket.pushLine(REQUEST); + socket.destroy(); + await until(() => socket.destroyed); + release(); + await until(() => handled.done); + // One more tick for the post-await destroyed guard. + await new Promise((resolve) => setImmediate(resolve)); + expect(socket.all).toBe(""); + }); + + it("cleans up a stream once on socket error and ignores late writes", async () => { + let lateWrite: (data: unknown) => void = () => {}; + let stops = 0; + const socket = accept(async (request) => ({ + response: { id: request.id, ok: true, result: {} }, + startStream: (writeData) => { + lateWrite = writeData; + writeData({ n: 1 }); + return () => { + stops += 1; + }; + }, + })); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"stream":"data"')); + + // Error on a still-writable socket: cleanup must emit the end frame, + // half-close, and the readline teardown must not throw. + socket.emit("error", new Error("peer reset")); + await until(() => socket.all.includes('"stream":"end"')); + expect(stops).toBe(1); + + // Late writes after cleanup are no-ops, and a duplicate cleanup + // (close after error) does not double-stop. + lateWrite({ n: 2 }); + socket.emit("close"); + await new Promise((resolve) => setImmediate(resolve)); + expect(stops).toBe(1); + expect(socket.all.split('"stream":"data"').length - 1).toBe(1); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts index ddf3e9e751..0cfb3b722b 100644 --- a/clients/mcpi/__tests__/daemon-stream.test.ts +++ b/clients/mcpi/__tests__/daemon-stream.test.ts @@ -223,6 +223,63 @@ describe("streamDaemon + ipc-glue", () => { ); }); + it("uses env-derived defaults and sends an explicit token", async () => { + const sock = freshSock(); + const prevDir = process.env.MCP_INSPECTOR_DAEMON_DIR; + const prevToken = process.env.MCP_INSPECTOR_DAEMON_TOKEN; + process.env.MCP_INSPECTOR_DAEMON_DIR = path.dirname(sock); + delete process.env.MCP_INSPECTOR_DAEMON_TOKEN; + try { + let seenToken: string | undefined; + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { + id: string; + token?: string; + }; + seenToken = req.token; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n"); + }); + }); + // No socketPath / timeoutMs: both fall back to defaults (the daemon + // dir env pins the socket path; the 60s default timer is cleared by + // the ok frame). + await streamDaemon({}, { token: "tok-1", onData: () => {} }); + expect(seenToken).toBe("tok-1"); + } finally { + if (prevDir === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR; + else process.env.MCP_INSPECTOR_DAEMON_DIR = prevDir; + if (prevToken !== undefined) + process.env.MCP_INSPECTOR_DAEMON_TOKEN = prevToken; + } + }); + + it("ignores frames after the stream has already ended", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + // ok + two end frames in one chunk: the second is handled by the + // same buffered-line loop after the promise has settled. + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + + "\n" + + JSON.stringify({ id: req.id, stream: "end" }) + + "\n" + + JSON.stringify({ id: req.id, stream: "end" }) + + "\n", + ); + }); + }); + await streamDaemon( + {}, + { socketPath: sock, timeoutMs: 2000, onData: () => {} }, + ); + }); + it("removeStaleDaemonSocket handles absent, dead, and live sockets", async () => { const sock = freshSock(); await removeStaleDaemonSocket(sock); diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts index 650fb835d7..8d1afc96af 100644 --- a/clients/mcpi/src/daemon/ipc-glue.ts +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -1,8 +1,5 @@ /** * Low-level Unix-socket accept / stale-socket helpers for {@link DaemonServer}. - * - * Outside the per-file coverage gate (see vitest.config.ts); behavior is - * covered by `__tests__/daemon-stream.test.ts`. */ import * as fs from "node:fs"; import * as net from "node:net"; @@ -127,6 +124,10 @@ export function acceptDaemonConnection( } }); const rl = createInterface({ input: socket, crlfDelay: Infinity }); + // readline re-emits input errors on the interface; without a listener a + // client RST would crash the daemon with an unhandled 'error' event. The + // socket's own error handler below owns the teardown. + rl.on("error", () => {}); const elicitationChannel = new ConnectionElicitationChannel(socket); rl.on("line", (line) => { void (async () => { diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts index 2470013458..88f6541b5b 100644 --- a/clients/mcpi/src/daemon/stream-client.ts +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -1,8 +1,5 @@ /** * Long-lived daemon stream client. - * - * Outside the per-file coverage gate (see vitest.config.ts); behavior is - * covered by `__tests__/daemon-stream.test.ts`. */ import { randomUUID } from "node:crypto"; import * as net from "node:net"; diff --git a/clients/mcpi/vitest.config.ts b/clients/mcpi/vitest.config.ts index 08e3063393..4e44fdcfce 100644 --- a/clients/mcpi/vitest.config.ts +++ b/clients/mcpi/vitest.config.ts @@ -32,12 +32,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "html", "json-summary"], include: ["src/**/*.ts"], - exclude: [ - "src/mcp-bin.ts", - "src/daemon/run.ts", - "src/daemon/ipc-glue.ts", - "src/daemon/stream-client.ts", - ], + exclude: ["src/mcp-bin.ts", "src/daemon/run.ts"], thresholds: { perFile: true, lines: 90, From b84feb12b9e3532f9ac8fd5a89b635c01550c82b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 09:55:58 -0700 Subject: [PATCH 06/69] docs(mcpi): record the skills/ tree and link #2461 from the alias notes (review 2e, 2g) AGENTS.md and README gain the skills/ entry in the project tree (distinct from .claude/skills/); the temporary @inspector/cli alias notes in AGENTS.md, clients/mcpi/README.md and tsup.config.ts now link the tracking issue #2461. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 +++++- README.md | 3 +++ clients/mcpi/README.md | 2 +- clients/mcpi/tsup.config.ts | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index db9048bfbf..af1eef2251 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,10 @@ inspector/ │ plus repo automation run from CI (the dependency, alert + SDK sweeps) ├── docs/ Task-oriented guides ├── specification/ Design/build specifications +├── skills/ End-user agent skills (skills/mcpi teaches an agent to drive +│ the `mcpi` CLI; tarball inclusion lands with the packaging +│ follow-up). Distinct from .claude/skills/ (repo procedures): +│ not indexed above and not checked by verify:skills └── .claude/skills/ The procedures (see the index above) ``` @@ -397,7 +401,7 @@ When asked to respond to a code review of a PR: The _procedure_ — where a given test file goes, which command runs it, how to diagnose a failing gate — is the `testing` skill. These are the rules. -- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API). +- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API — #2461 tracks promoting that surface to a shared area). - **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise. - **In unit tests that expect error output, suppress it from the console.** - **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.** diff --git a/README.md b/README.md index ec2a3df89d..1a92887cea 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ inspector/ │ and repo automation run from CI (the dependency, Dependabot-alert and SDK sweeps) ├── docs/ Task-oriented guides — see below ├── specification/ Design/build specifications +├── skills/ End-user agent skills (e.g. skills/mcpi teaches an agent to +│ drive the `mcpi` CLI) — distinct from .claude/skills/, +│ which holds this repo's own procedures ├── .claude/skills/ Agent skills: the repo's procedures, invokable by name ├── AGENTS.md Contribution rules for agents AND humans └── README.md You are here diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index 4ae9c2d1ac..2f569d677c 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -2,7 +2,7 @@ **Experimental** separate client — not part of the published `@modelcontextprotocol/inspector` package. Connect once, then run many MCP commands against a named session via an implicit local daemon (ssh-agent style). -> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists. +> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists (tracked by [#2461](https://github.com/modelcontextprotocol/inspector/issues/2461)). ## Install / run (from this repo) diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts index cb239c7daa..c4beb06d17 100644 --- a/clients/mcpi/tsup.config.ts +++ b/clients/mcpi/tsup.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ target: "node22", platform: "node", // Bundle core + one-shot CLI internals (handlers, error-handler, OAuth helpers). - // Temporary reach-in until a dedicated shared package exists — see README. + // Temporary reach-in until a dedicated shared package exists — tracked by + // https://github.com/modelcontextprotocol/inspector/issues/2461 (see README). noExternal: [/^@inspector\/core/, /^@inspector\/cli/], // Mirrors clients/cli/tsup.config.ts (which documents each entry's story): // this client declares NO runtime dependencies (AGENTS.md dependency- From 516dffc2e42df2cd9083c117d7ac1ce9f24b5b3b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 10:27:58 -0700 Subject: [PATCH 07/69] =?UTF-8?q?docs(mcpi):=20document=20per-session=20co?= =?UTF-8?q?ntainer=20isolation=20for=20untrusted=20stdio=20servers=20(revi?= =?UTF-8?q?ew=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon token gates who can command the daemon, not what a spawned server can do. Record the zero-code recipe — wrapping the stdio command in `docker run -i` — as the way to isolate an untrusted server, per the review recommendation. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index 2f569d677c..c158cbdb21 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -84,6 +84,22 @@ mcpi tools/list See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. +## Isolating untrusted stdio servers + +The daemon's token controls **who can command the daemon**, not **what a +spawned server can do**: a stdio MCP server runs with your full user +privileges, like in any MCP host. To isolate a server you don't fully trust, +wrap the stdio command in a container — this works today with no mcpi +support: + +```bash +mcpi connect docker run -i --rm --network none -v "$PWD:/work:ro" +``` + +Tighten or loosen the flags per server (drop `--network none` if it needs +egress; adjust the mount to what it should see). HTTP/SSE targets run no +local code, so they need no process isolation. + ## Protocol era support mcpi shares `core`'s `InspectorClient`, so it negotiates whichever era From 4245080ab66f37b0eb36cd052d5c256cbf75f90b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 10:33:08 -0700 Subject: [PATCH 08/69] fix(mcpi): resolve bare stdio command names against the caller's PATH (review 1c follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon inherits the environment of whichever mcpi invocation first spawned it, so a bare command name like `node` was looked up in that stale PATH — a different nvm version or venv could supply a different binary than the caller's shell would. The connect front end now resolves bare names (no path separator) to an absolute path using the caller's PATH before the config crosses the IPC boundary, so the daemon spawns exactly the caller's binary and no environment is forwarded. Unresolvable names pass through unchanged so the daemon's spawn error stays the user-visible failure; commands with a separator still resolve against the pinned session cwd. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcpi/__tests__/resolve-command.test.ts | 81 +++++++++++++++++++ clients/mcpi/src/session/mcp.ts | 11 +++ clients/mcpi/src/session/resolve-command.ts | 48 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 clients/mcpi/__tests__/resolve-command.test.ts create mode 100644 clients/mcpi/src/session/resolve-command.ts diff --git a/clients/mcpi/__tests__/resolve-command.test.ts b/clients/mcpi/__tests__/resolve-command.test.ts new file mode 100644 index 0000000000..2466f18fde --- /dev/null +++ b/clients/mcpi/__tests__/resolve-command.test.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { resolveCommandPath } from "../src/session/resolve-command.js"; + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-resolve-")); + +afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +function makeExecutable(dir: string, name: string): string { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, "#!/bin/sh\n", { mode: 0o755 }); + return file; +} + +describe("resolveCommandPath", () => { + it("resolves a bare name to the first executable on PATH", () => { + const first = path.join(tmpRoot, "first"); + const second = path.join(tmpRoot, "second"); + const expected = makeExecutable(first, "mytool"); + makeExecutable(second, "mytool"); + const env = { PATH: [first, second].join(path.delimiter) }; + expect(resolveCommandPath("mytool", env)).toBe(expected); + }); + + it("skips PATH entries where the name is missing or not a file", () => { + const missing = path.join(tmpRoot, "missing"); + const hasDir = path.join(tmpRoot, "has-dir"); + fs.mkdirSync(path.join(hasDir, "mytool2"), { recursive: true }); + const real = path.join(tmpRoot, "real"); + const expected = makeExecutable(real, "mytool2"); + const env = { PATH: [missing, hasDir, "", real].join(path.delimiter) }; + expect(resolveCommandPath("mytool2", env)).toBe(expected); + }); + + it("skips non-executable files", () => { + const dir = path.join(tmpRoot, "non-exec"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "mytool3"), "", { mode: 0o644 }); + const real = path.join(tmpRoot, "exec"); + const expected = makeExecutable(real, "mytool3"); + const env = { PATH: [dir, real].join(path.delimiter) }; + expect(resolveCommandPath("mytool3", env)).toBe(expected); + }); + + it("returns commands with a path separator unchanged", () => { + expect(resolveCommandPath("./server.js", { PATH: tmpRoot })).toBe( + "./server.js", + ); + expect(resolveCommandPath("/usr/bin/env", { PATH: tmpRoot })).toBe( + "/usr/bin/env", + ); + }); + + it("returns the name unchanged when not found on PATH", () => { + const env = { PATH: path.join(tmpRoot, "empty-dir") }; + expect(resolveCommandPath("definitely-not-a-real-tool", env)).toBe( + "definitely-not-a-real-tool", + ); + }); + + it("handles an empty command and an unset PATH", () => { + expect(resolveCommandPath("", { PATH: tmpRoot })).toBe(""); + expect(resolveCommandPath("mytool", {})).toBe("mytool"); + }); + + it("defaults to process.env", () => { + // `sh` exists on every POSIX PATH; on Windows this still exercises the + // default-env branch even if the lookup misses. + const resolved = resolveCommandPath("sh"); + if (process.platform !== "win32") { + expect(path.isAbsolute(resolved)).toBe(true); + } else { + expect(typeof resolved).toBe("string"); + } + }); +}); diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index be3fdfe1f4..f9ebec1d3d 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -41,6 +41,7 @@ import { import { authorizeInFrontend } from "./authorize.js"; import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; import { resolveToolCallArgs } from "./parse-tool-args.js"; +import { resolveCommandPath } from "./resolve-command.js"; import { dispatchSessionRpc, hoistAtSession, @@ -376,6 +377,16 @@ function registerConnect(program: CommandType): void { if (serverConfig.type === "stdio" && !serverConfig.cwd) { serverConfig = { ...serverConfig, cwd: process.cwd() }; } + // Same staleness problem for bare command names: the daemon would look + // `node` up in the PATH of whichever mcpi invocation first spawned it. + // Resolve against the CALLER's PATH here so the daemon spawns exactly + // the binary this shell would have run. + if (serverConfig.type === "stdio") { + const resolved = resolveCommandPath(serverConfig.command); + if (resolved !== serverConfig.command) { + serverConfig = { ...serverConfig, command: resolved }; + } + } const serverSettings = withEmaOverride( withElicitOverride( withEraOverride( diff --git a/clients/mcpi/src/session/resolve-command.ts b/clients/mcpi/src/session/resolve-command.ts new file mode 100644 index 0000000000..75a556f6c6 --- /dev/null +++ b/clients/mcpi/src/session/resolve-command.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Resolve a bare stdio command name to an absolute path using the CALLER's + * `PATH`, before the config crosses the IPC boundary. + * + * The daemon inherits the environment of whichever mcpi invocation first + * spawned it, so a bare `node` would otherwise be looked up in a stale + * `PATH` (a different nvm version, a venv from another shell) — the daemon + * could run a different binary than the one the user's shell would. + * Resolving here spawns exactly the caller's binary without forwarding any + * environment across the boundary. + * + * Commands containing a path separator are returned unchanged: the daemon + * resolves those against the session cwd, which connect already pins to the + * caller's cwd. Names not found on `PATH` are also returned unchanged so the + * daemon's spawn error remains the user-visible failure. + */ +export function resolveCommandPath( + command: string, + env: NodeJS.ProcessEnv = process.env, +): string { + if (!command || command.includes("/") || command.includes(path.sep)) { + return command; + } + const pathVar = env.PATH ?? ""; + /* v8 ignore next 4 -- platform-only branch: PATHEXT applies on win32 only */ + const extensions = + process.platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") + : [""]; + for (const dir of pathVar.split(path.delimiter)) { + if (!dir) continue; + for (const ext of extensions) { + const candidate = path.join(dir, command + ext); + try { + const stat = fs.statSync(candidate); + if (!stat.isFile()) continue; + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // Not there / not executable — keep looking. + } + } + } + return command; +} From 889ed77e65e27dc4611789fcc58ac2ef3b14f91f Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 23 Sep 2026 15:01:02 -0700 Subject: [PATCH 09/69] feat(daemon-cli): rename mcpi to mcpdo, adopt connection vocabulary, bundle into the package Maintainer-approved decisions on the #1783 review thread: - Bin name: `mcpdo` (conflict-free on npm; `mcpi` collides with an unrelated package). Root `bin` now installs it and `files` ships `clients/daemon-cli/build` and `skills/mcpdo`, so `npm i -g @modelcontextprotocol/inspector` provides the experimental client (~200 KB compressed addition). - Internal name: `clients/daemon-cli` (role-based, like cli/tui/web/ launcher), insulated from future bin renames. Root scripts are now build:/validate:/coverage:daemon-cli. - Vocabulary: the daemon holds named live connections, not resumable sessions, so the session wording over-promised and collided with MCP transport terminology. Commands are now `connections/list|show|use`; `connect`/`disconnect` stay top-level lifecycle verbs. The global flag is `--connection ` with `--conn` as a documented shorthand (argv-level alias, one option registration). Env opt-in renamed to MCP_ALLOW_DEFAULT_CONNECTION; daemon dirs move to $TMPDIR/mcp-conn-/. IdP *session* wording is kept where it names the enterprise IdP login session (a different concept). - Shared cli helpers consumed only by mcpdo follow suit (annotateServerEntriesWithConnections, CONNECTION_RPC_METHODS, and the servers/list `connection` annotation field). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 24 +- README.md | 10 +- clients/cli/__tests__/method-types.test.ts | 16 +- clients/cli/__tests__/servers-list.test.ts | 26 +- clients/cli/src/cli-oauth-navigation.ts | 2 +- clients/cli/src/handlers/consume-outcome.ts | 2 +- clients/cli/src/handlers/method-types.ts | 10 +- clients/cli/src/handlers/servers-list.ts | 32 +-- clients/cli/src/style.ts | 2 +- clients/{mcpi => daemon-cli}/README.md | 119 ++++---- .../__tests__/agent-help.test.ts | 10 +- .../__tests__/authorize.test.ts | 20 +- .../__tests__/connection-stored-auth.test.ts} | 6 +- .../__tests__/daemon-connections.test.ts} | 96 +++---- .../__tests__/daemon-coverage.test.ts | 89 +++--- .../__tests__/daemon-ipc-glue.test.ts | 0 .../__tests__/daemon-paths.test.ts | 6 +- .../__tests__/daemon-private.test.ts | 16 +- .../__tests__/daemon-stream.test.ts | 0 .../__tests__/dispatch.test.ts | 81 +++--- .../__tests__/elicitation-bridge.test.ts | 0 .../__tests__/elicitation-client.test.ts | 0 .../__tests__/elicitation-prompt.test.ts | 30 +-- .../__tests__/ema-commands.test.ts | 26 +- .../__tests__/ema.test.ts | 41 +-- .../__tests__/form-prompt.test.ts | 4 +- .../__tests__/form-schema.test.ts | 2 +- .../__tests__/format-connection.test.ts} | 86 +++--- .../__tests__/helpers/mcp-runner.ts | 8 +- .../__tests__/hoist-connection.test.ts | 50 ++++ .../__tests__/mcp-auth-coverage.test.ts | 40 +-- .../__tests__/mcp-connection.test.ts} | 56 ++-- .../__tests__/mcp-coverage.test.ts | 24 +- .../__tests__/parse-tool-args.test.ts | 2 +- .../__tests__/resolve-command.test.ts | 4 +- .../__tests__/sanitize.test.ts | 4 +- clients/{mcpi => daemon-cli}/eslint.config.js | 0 .../{mcpi => daemon-cli}/package-lock.json | 6 +- clients/{mcpi => daemon-cli}/package.json | 6 +- .../src/connection}/authorize.ts | 10 +- .../src/connection}/dispatch.ts | 36 +-- .../src/connection}/elicitation-prompt.ts | 4 +- .../src/connection}/ema.ts | 16 +- .../src/connection}/form-prompt.ts | 2 +- .../src/connection}/form-schema.ts | 2 +- .../src/connection/format-connection.ts} | 64 ++--- .../src/connection}/format-human.ts | 65 ++--- .../src/connection}/mcp.ts | 253 ++++++++++-------- .../src/connection}/parse-tool-args.ts | 2 +- .../src/connection}/private-env.ts | 2 +- .../src/connection}/resolve-command.ts | 4 +- .../src/connection}/sanitize.ts | 0 .../src/connection}/stored-auth.ts | 0 .../{mcpi => daemon-cli}/src/daemon/auth.ts | 0 .../{mcpi => daemon-cli}/src/daemon/client.ts | 6 +- .../src/daemon/connections.ts} | 168 ++++++------ .../src/daemon/elicitation-bridge.ts | 0 .../{mcpi => daemon-cli}/src/daemon/ensure.ts | 8 +- .../src/daemon/framing.ts | 0 .../{mcpi => daemon-cli}/src/daemon/index.ts | 10 +- .../src/daemon/ipc-glue.ts | 2 +- .../{mcpi => daemon-cli}/src/daemon/paths.ts | 8 +- .../src/daemon/protocol.ts | 62 ++--- .../{mcpi => daemon-cli}/src/daemon/run.ts | 8 +- .../{mcpi => daemon-cli}/src/daemon/server.ts | 66 ++--- .../src/daemon/stream-client.ts | 6 +- clients/{mcpi => daemon-cli}/src/mcp-bin.ts | 2 +- clients/{mcpi => daemon-cli}/tsconfig.json | 2 +- .../{mcpi => daemon-cli}/tsconfig.test.json | 0 clients/{mcpi => daemon-cli}/tsup.config.ts | 0 clients/{mcpi => daemon-cli}/vitest.config.ts | 0 clients/mcpi/__tests__/hoist-session.test.ts | 22 -- clients/tui/package-lock.json | 30 --- clients/web/package-lock.json | 120 --------- core/auth/node/runner-interactive-oauth.ts | 2 +- core/mcp/types.ts | 4 +- package.json | 25 +- scripts/install-clients.mjs | 2 +- scripts/lib/workflow-gate.test.mjs | 4 +- scripts/sdk-watch.mjs | 2 +- scripts/verify-bundle-externals.mjs | 8 +- scripts/verify-format-coverage.mjs | 2 +- scripts/verify-test-timeouts.mjs | 4 +- scripts/verify-test-timeouts.test.mjs | 2 +- skills/mcpdo/SKILL.md | 54 ++++ skills/mcpi/SKILL.md | 54 ---- specification/v2_cli_tui_launcher.md | 6 +- specification/v2_cli_v2.md | 117 ++++---- 88 files changed, 1092 insertions(+), 1130 deletions(-) rename clients/{mcpi => daemon-cli}/README.md (56%) rename clients/{mcpi => daemon-cli}/__tests__/agent-help.test.ts (62%) rename clients/{mcpi => daemon-cli}/__tests__/authorize.test.ts (85%) rename clients/{mcpi/__tests__/session-stored-auth.test.ts => daemon-cli/__tests__/connection-stored-auth.test.ts} (98%) rename clients/{mcpi/__tests__/daemon-sessions.test.ts => daemon-cli/__tests__/daemon-connections.test.ts} (86%) rename clients/{mcpi => daemon-cli}/__tests__/daemon-coverage.test.ts (90%) rename clients/{mcpi => daemon-cli}/__tests__/daemon-ipc-glue.test.ts (100%) rename clients/{mcpi => daemon-cli}/__tests__/daemon-paths.test.ts (94%) rename clients/{mcpi => daemon-cli}/__tests__/daemon-private.test.ts (96%) rename clients/{mcpi => daemon-cli}/__tests__/daemon-stream.test.ts (100%) rename clients/{mcpi => daemon-cli}/__tests__/dispatch.test.ts (75%) rename clients/{mcpi => daemon-cli}/__tests__/elicitation-bridge.test.ts (100%) rename clients/{mcpi => daemon-cli}/__tests__/elicitation-client.test.ts (100%) rename clients/{mcpi => daemon-cli}/__tests__/elicitation-prompt.test.ts (89%) rename clients/{mcpi => daemon-cli}/__tests__/ema-commands.test.ts (84%) rename clients/{mcpi => daemon-cli}/__tests__/ema.test.ts (87%) rename clients/{mcpi => daemon-cli}/__tests__/form-prompt.test.ts (99%) rename clients/{mcpi => daemon-cli}/__tests__/form-schema.test.ts (99%) rename clients/{mcpi/__tests__/format-session.test.ts => daemon-cli/__tests__/format-connection.test.ts} (93%) rename clients/{mcpi => daemon-cli}/__tests__/helpers/mcp-runner.ts (89%) create mode 100644 clients/daemon-cli/__tests__/hoist-connection.test.ts rename clients/{mcpi => daemon-cli}/__tests__/mcp-auth-coverage.test.ts (90%) rename clients/{mcpi/__tests__/mcp-session.test.ts => daemon-cli/__tests__/mcp-connection.test.ts} (75%) rename clients/{mcpi => daemon-cli}/__tests__/mcp-coverage.test.ts (94%) rename clients/{mcpi => daemon-cli}/__tests__/parse-tool-args.test.ts (98%) rename clients/{mcpi => daemon-cli}/__tests__/resolve-command.test.ts (94%) rename clients/{mcpi => daemon-cli}/__tests__/sanitize.test.ts (94%) rename clients/{mcpi => daemon-cli}/eslint.config.js (100%) rename clients/{mcpi => daemon-cli}/package-lock.json (99%) rename clients/{mcpi => daemon-cli}/package.json (86%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/authorize.ts (93%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/dispatch.ts (81%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/elicitation-prompt.ts (97%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/ema.ts (93%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/form-prompt.ts (99%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/form-schema.ts (98%) rename clients/{mcpi/src/session/format-session.ts => daemon-cli/src/connection/format-connection.ts} (85%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/format-human.ts (93%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/mcp.ts (82%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/parse-tool-args.ts (98%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/private-env.ts (92%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/resolve-command.ts (91%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/sanitize.ts (100%) rename clients/{mcpi/src/session => daemon-cli/src/connection}/stored-auth.ts (100%) rename clients/{mcpi => daemon-cli}/src/daemon/auth.ts (100%) rename clients/{mcpi => daemon-cli}/src/daemon/client.ts (97%) rename clients/{mcpi/src/daemon/sessions.ts => daemon-cli/src/daemon/connections.ts} (75%) rename clients/{mcpi => daemon-cli}/src/daemon/elicitation-bridge.ts (100%) rename clients/{mcpi => daemon-cli}/src/daemon/ensure.ts (95%) rename clients/{mcpi => daemon-cli}/src/daemon/framing.ts (100%) rename clients/{mcpi => daemon-cli}/src/daemon/index.ts (87%) rename clients/{mcpi => daemon-cli}/src/daemon/ipc-glue.ts (98%) rename clients/{mcpi => daemon-cli}/src/daemon/paths.ts (93%) rename clients/{mcpi => daemon-cli}/src/daemon/protocol.ts (79%) rename clients/{mcpi => daemon-cli}/src/daemon/run.ts (81%) rename clients/{mcpi => daemon-cli}/src/daemon/server.ts (89%) rename clients/{mcpi => daemon-cli}/src/daemon/stream-client.ts (95%) rename clients/{mcpi => daemon-cli}/src/mcp-bin.ts (93%) rename clients/{mcpi => daemon-cli}/tsconfig.json (98%) rename clients/{mcpi => daemon-cli}/tsconfig.test.json (100%) rename clients/{mcpi => daemon-cli}/tsup.config.ts (100%) rename clients/{mcpi => daemon-cli}/vitest.config.ts (100%) delete mode 100644 clients/mcpi/__tests__/hoist-session.test.ts create mode 100644 skills/mcpdo/SKILL.md delete mode 100644 skills/mcpi/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index af1eef2251..aff6374740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Inspector V2 This is an application for inspecting MCP servers. It has four client -surfaces — Web, TUI, one-shot CLI, and the experimental session CLI (`mcpi`) — +surfaces — Web, TUI, one-shot CLI, and the experimental session CLI (`mcpdo`) — over a shared `core/`. **This file holds the _rules_: the conventions a reviewer cites against a diff.** @@ -42,9 +42,9 @@ inspector/ │ │ ├── server/ Node-only dev/prod backend wiring │ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab │ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias) -│ ├── mcpi/ Experimental session CLI (`mcpi` bin — connect once, many -│ │ commands; implicit Unix-socket session daemon). Not part -│ │ of the published package yet — see clients/mcpi/README.md +│ ├── daemon-cli/ Experimental connection CLI (`mcpdo` bin — connect once, many +│ │ commands; implicit Unix-socket connection daemon). Bundled +│ │ into the published package — see clients/daemon-cli/README.md │ ├── tui/ Ink + React terminal UI (tsup bundle) │ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process ├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json) @@ -61,10 +61,10 @@ inspector/ │ plus repo automation run from CI (the dependency, alert + SDK sweeps) ├── docs/ Task-oriented guides ├── specification/ Design/build specifications -├── skills/ End-user agent skills (skills/mcpi teaches an agent to drive -│ the `mcpi` CLI; tarball inclusion lands with the packaging -│ follow-up). Distinct from .claude/skills/ (repo procedures): -│ not indexed above and not checked by verify:skills +├── skills/ End-user agent skills (skills/mcpdo teaches an agent to drive +│ the `mcpdo` CLI; shipped in the published tarball). Distinct +│ from .claude/skills/ (repo procedures): not indexed above +│ and not checked by verify:skills └── .claude/skills/ The procedures (see the index above) ``` @@ -98,13 +98,13 @@ The reasoning behind each of these, and what breaks when it is ignored, is the - **Every runtime dependency `core/` imports is declared in the repo-root `package.json` and nowhere else.** That is the MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) and, since #2195, the rest of what `core/` reaches: `ajv`, `atomically`, `chokidar`, `hono`, `@napi-rs/keyring`, `pino`, `proper-lockfile`, `react`, `undici`, `zod`. So is anything reached only through root-owned code with no manifest of its own (`test-servers/src`, `core/`). The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one. - **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all four. -- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli`, `clients/mcpi` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back. +- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli`, `clients/daemon-cli` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back. - **A package that moves to the root moves its `vitest.shared.mts` pin with it.** Left pointing at `/node_modules` a pin resolves to a directory that no longer exists — or, where a transitive copy happens to sit there (`chokidar` under `vite`, `react` as a peer of `react-dom` and `ink`), to the very duplicate the pin list exists to prevent. **`react` and `react-dom` are the deliberate exception** and stay pinned per client, so a client's renderer and the React it calls into come from one install; every other root-owned pin resolves from the repo root. - **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. - **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale. - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way). - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301). -- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,mcpi,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. +- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,mcpdo,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. - **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`). - **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both. - **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading. @@ -401,12 +401,12 @@ When asked to respond to a code review of a PR: The _procedure_ — where a given test file goes, which command runs it, how to diagnose a failing gate — is the `testing` skill. These are the rules. -- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/mcpi`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpi** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/mcpi/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API — #2461 tracks promoting that surface to a shared area). +- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/daemon-cli`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpdo** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/daemon-cli/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API — #2461 tracks promoting that surface to a shared area). - **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise. - **In unit tests that expect error output, suppress it from the console.** - **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.** - **`clients/web`**: `.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — _placement is the manifest_, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`). - - **`clients/cli`, `clients/mcpi`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`. + - **`clients/cli`, `clients/daemon-cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`. - **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0. - **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling *inside* the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance. - **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. diff --git a/README.md b/README.md index 1a92887cea..0f1a701a35 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ inspector/ ├── clients/ │ ├── web/ Web client (Vite + React + Mantine). src/ = browser app; server/ = Node backend │ ├── cli/ CLI client (tsup bundle, @inspector/core alias) -│ ├── mcpi/ Experimental session CLI (`mcpi` bin) — not part of the -│ │ published package; see clients/mcpi/README.md +│ ├── daemon-cli/ Experimental connection CLI (`mcpdo` bin) — bundled into the +│ │ published package; see clients/daemon-cli/README.md │ ├── tui/ TUI client (Ink + React, tsup bundle) │ └── launcher/ Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ Shared code consumed via the `@inspector/core` alias (no package.json) @@ -62,8 +62,8 @@ inspector/ │ and repo automation run from CI (the dependency, Dependabot-alert and SDK sweeps) ├── docs/ Task-oriented guides — see below ├── specification/ Design/build specifications -├── skills/ End-user agent skills (e.g. skills/mcpi teaches an agent to -│ drive the `mcpi` CLI) — distinct from .claude/skills/, +├── skills/ End-user agent skills (e.g. skills/mcpdo teaches an agent to +│ drive the `mcpdo` CLI) — distinct from .claude/skills/, │ which holds this repo's own procedures ├── .claude/skills/ Agent skills: the repo's procedures, invokable by name ├── AGENTS.md Contribution rules for agents AND humans @@ -71,7 +71,7 @@ inspector/ ``` Each client has its own README with client-specific detail: -[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [mcpi](./clients/mcpi/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). +[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [mcpdo](./clients/daemon-cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). ## Documentation diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts index 85230043d8..76edddaf24 100644 --- a/clients/cli/__tests__/method-types.test.ts +++ b/clients/cli/__tests__/method-types.test.ts @@ -2,16 +2,18 @@ import { describe, it, expect } from "vitest"; import { isOneShotMethod, ONE_SHOT_METHODS, - SESSION_RPC_METHODS, + CONNECTION_RPC_METHODS, } from "../src/handlers/method-types.js"; -describe("SESSION_RPC_METHODS", () => { +describe("CONNECTION_RPC_METHODS", () => { it("lists the full RPC method set supported by runMethod", () => { - expect(SESSION_RPC_METHODS).toContain("tools/list"); - expect(SESSION_RPC_METHODS).toContain("tools/call"); - expect(SESSION_RPC_METHODS).toContain("logging/tail"); - expect(SESSION_RPC_METHODS).toContain("roots/set"); - expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length); + expect(CONNECTION_RPC_METHODS).toContain("tools/list"); + expect(CONNECTION_RPC_METHODS).toContain("tools/call"); + expect(CONNECTION_RPC_METHODS).toContain("logging/tail"); + expect(CONNECTION_RPC_METHODS).toContain("roots/set"); + expect(new Set(CONNECTION_RPC_METHODS).size).toBe( + CONNECTION_RPC_METHODS.length, + ); }); }); diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index daa1c44418..7f83f57459 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -7,7 +7,7 @@ import { } from "./helpers/fixtures.js"; import { expectCliSuccess } from "./helpers/assertions.js"; import { - annotateServerEntriesWithSessions, + annotateServerEntriesWithConnections, listServerEntries, sanitizeServerConfig, sanitizeServerSettings, @@ -60,33 +60,41 @@ describe("summarizeServerConfig", () => { }); }); -describe("annotateServerEntriesWithSessions", () => { +describe("annotateServerEntriesWithConnections", () => { const entries = [ { name: "a", type: "stdio", detail: "node a" }, { name: "b", type: "stdio", detail: "node b" }, ]; - it("returns entries unchanged when there are no sessions", () => { - expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries); + it("returns entries unchanged when there are no connections", () => { + expect(annotateServerEntriesWithConnections(entries, [])).toBe(entries); }); it("marks matching entry names and MRU", () => { expect( - annotateServerEntriesWithSessions(entries, [ + annotateServerEntriesWithConnections(entries, [ { name: "b", isMru: true }, { name: "other" }, ]), ).toEqual([ { name: "a", type: "stdio", detail: "node a" }, - { name: "b", type: "stdio", detail: "node b", session: "b", isMru: true }, + { + name: "b", + type: "stdio", + detail: "node b", + connection: "b", + isMru: true, + }, ]); }); - it("omits isMru when the session is not MRU", () => { + it("omits isMru when the connection is not MRU", () => { expect( - annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]), + annotateServerEntriesWithConnections(entries, [ + { name: "a", isMru: false }, + ]), ).toEqual([ - { name: "a", type: "stdio", detail: "node a", session: "a" }, + { name: "a", type: "stdio", detail: "node a", connection: "a" }, { name: "b", type: "stdio", detail: "node b" }, ]); }); diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts index c0ad05fda0..22f8a3330a 100644 --- a/clients/cli/src/cli-oauth-navigation.ts +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -54,7 +54,7 @@ export type CliOAuthNavigationOptions = { * Build the printed prompt line for a given authorize URL. Receives the * (possibly OSC-8-linked) display string and whether stderr is a TTY. * Defaults to the CLI's own "Please navigate to: " framing. Override - * when a different caller needs different wording — e.g. mcpi, addressed to + * when a different caller needs different wording — e.g. mcpdo, addressed to * whatever is running it (which may be an agent that must relay the link to * a human) rather than to a human reading the terminal directly. */ diff --git a/clients/cli/src/handlers/consume-outcome.ts b/clients/cli/src/handlers/consume-outcome.ts index 5db0738be9..c1f797577d 100644 --- a/clients/cli/src/handlers/consume-outcome.ts +++ b/clients/cli/src/handlers/consume-outcome.ts @@ -8,7 +8,7 @@ import type { MethodArgs, MethodOutcome } from "./method-types.js"; * Stream methods stay attached until SIGINT/SIGTERM. * * TODO(#1432): long-lived stream path does not yet handle EPIPE / stdout error - * (session CLI / `mcpi` follow-up). + * (connection CLI / `mcpdo` follow-up). */ export async function consumeMethodOutcome( outcome: MethodOutcome, diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 9c5260b94c..c8a101dac7 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -96,16 +96,16 @@ export type MethodOutcome = * * TODO(#1432): several of these (subscribe, tasks, roots, logging/tail, …) are * not exposed by `mcp-inspector --cli` today; they exist for the experimental - * session CLI (`mcpi`) and other Node runners that share this dispatcher. + * connection CLI (`mcpdo`) and other Node runners that share this dispatcher. * * Deliberately excludes `"initialize"` — that's still a valid {@link * ONE_SHOT_METHODS} entry (scripting parity with the literal wire method - * name), but for `mcpi` it read as "send another initialize", which it never - * did (it only replays cached connect-time state). `mcpi sessions/show` + * name), but for `mcpdo` it read as "send another initialize", which it never + * did (it only replays cached connect-time state). `mcpdo connections/show` * covers the same data (server info, capabilities, negotiated era) alongside * daemon session bookkeeping instead. */ -export const SESSION_RPC_METHODS = [ +export const CONNECTION_RPC_METHODS = [ "tools/list", "tools/call", "resources/list", @@ -130,7 +130,7 @@ export const SESSION_RPC_METHODS = [ "skills/get", ] as const; -export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number]; +export type SessionRpcMethod = (typeof CONNECTION_RPC_METHODS)[number]; /** * Methods accepted by `mcp-inspector --cli` (plus catalog-only diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index d580f47c07..64b73f3a0c 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -16,40 +16,40 @@ export type ServerListEntry = { /** Command line, URL, or other short identity for display. */ detail: string; /** - * Optional live-session name when a caller annotates catalog entries - * with connected sessions (omitted for plain catalog listing). + * Optional live-connection name when a caller annotates catalog entries + * with live connections (omitted for plain catalog listing). */ - session?: string; - /** True when that session is the most-recently-used connected session. */ + connection?: string; + /** True when that connection is the most-recently-used connection. */ isMru?: boolean; }; -/** Minimal session shape needed to annotate catalog entries. */ -export type SessionListRef = { +/** Minimal connection shape needed to annotate catalog entries. */ +export type ConnectionListRef = { name: string; isMru?: boolean; }; /** - * Mark catalog entries that have a live session with the same name. + * Mark catalog entries that have a live connection with the same name. * Does not mutate `entries`. * - * TODO(#1432): consumed by the experimental session CLI (`mcpi`); kept here so + * TODO(#1432): consumed by the experimental connection CLI (`mcpdo`); kept here so * that client can reuse catalog listing without duplicating this helper. */ -export function annotateServerEntriesWithSessions( +export function annotateServerEntriesWithConnections( entries: ServerListEntry[], - sessions: SessionListRef[], + connections: ConnectionListRef[], ): ServerListEntry[] { - if (sessions.length === 0) return entries; - const byName = new Map(sessions.map((s) => [s.name, s] as const)); + if (connections.length === 0) return entries; + const byName = new Map(connections.map((s) => [s.name, s] as const)); return entries.map((entry) => { - const session = byName.get(entry.name); - if (!session) return entry; + const connection = byName.get(entry.name); + if (!connection) return entry; return { ...entry, - session: session.name, - ...(session.isMru === true ? { isMru: true } : {}), + connection: connection.name, + ...(connection.isMru === true ? { isMru: true } : {}), }; }); } diff --git a/clients/cli/src/style.ts b/clients/cli/src/style.ts index dc2ed15c53..748dc5a91b 100644 --- a/clients/cli/src/style.ts +++ b/clients/cli/src/style.ts @@ -5,7 +5,7 @@ import type { OutputFormat } from "./handlers/format-output.js"; * * TODO(#1432): the CLI OAuth path only needs {@link Style.link} today; bold / * color helpers and {@link styleFromOpts} are used by the experimental session - * CLI (`mcpi`) human formatter. + * CLI (`mcpdo`) human formatter. */ export type Style = { /** Whether ANSI styling is enabled. */ diff --git a/clients/mcpi/README.md b/clients/daemon-cli/README.md similarity index 56% rename from clients/mcpi/README.md rename to clients/daemon-cli/README.md index c158cbdb21..abaa54137d 100644 --- a/clients/mcpi/README.md +++ b/clients/daemon-cli/README.md @@ -1,37 +1,46 @@ -# MCP Inspector session CLI (`mcpi`) +# MCP Inspector connection CLI (`mcpdo`) -**Experimental** separate client — not part of the published `@modelcontextprotocol/inspector` package. Connect once, then run many MCP commands against a named session via an implicit local daemon (ssh-agent style). +**Experimental** separate client — **bundled into the published `@modelcontextprotocol/inspector` package** as the `mcpdo` bin. Connect once, then run many MCP commands against a named connection via an implicit local daemon (ssh-agent style). -> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists (tracked by [#2461](https://github.com/modelcontextprotocol/inspector/issues/2461)). +> **Layout note:** Source lives in `clients/daemon-cli/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists (tracked by [#2461](https://github.com/modelcontextprotocol/inspector/issues/2461)). + +## Install + +`mcpdo` ships with the published package: + +```bash +npm install -g @modelcontextprotocol/inspector +mcpdo --help +``` ## Install / run (from this repo) -Build, then put `mcpi` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): +Build, then put `mcpdo` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): ```bash # from the repo root — install deps once if needed npm install -cd clients/mcpi +cd clients/daemon-cli npm run build npm link -mcpi --help +mcpdo --help ``` -Rebuild after pulling source changes (`npm run build` in `clients/mcpi`). You usually do **not** need to re-link unless the package `bin` entry changes. +Rebuild after pulling source changes (`npm run build` in `clients/daemon-cli`). You usually do **not** need to re-link unless the package `bin` entry changes. ### Development loop -`mcpi` itself is a short-lived process re-executed on every invocation, so a +`mcpdo` itself is a short-lived process re-executed on every invocation, so a plain rebuild is enough for its changes to take effect on the next command. -The **session daemon** (`build/daemon.js`) is different: `ensureDaemon` (see +The **connection daemon** (`build/daemon.js`) is different: `ensureDaemon` (see `src/daemon/ensure.ts`) reuses an already-running daemon without checking its code version, so a daemon started before your rebuild keeps running stale code indefinitely. Use `npm run build:dev` instead of `npm run build` while iterating: it runs -`mcpi daemon stop` first (harmless/no-op if no daemon is running — it treats +`mcpdo daemon stop` first (harmless/no-op if no daemon is running — it treats "daemon not running" as success) and then `tsup`, so the next daemon-backed command (`connect`, `tools/list`, …) spawns a fresh daemon from the code you just built. Commands that never touch the daemon (`servers/list`, @@ -41,46 +50,46 @@ for those. Without linking, run the built file directly: ```bash -node clients/mcpi/build/mcp-bin.js --help +node clients/daemon-cli/build/mcp-bin.js --help ``` Remove the link when you’re done: ```bash -npm unlink -g @modelcontextprotocol/mcpi +npm unlink -g @modelcontextprotocol/daemon-cli ``` ## Usage ```bash -mcpi servers/list --config path/to/mcp.json -mcpi servers/show test-stdio --config path/to/mcp.json -mcpi connect test-stdio --config path/to/mcp.json -mcpi connect my-http --config path/to/mcp.json --relogin # ignore stored OAuth; login only if auth required -mcpi auth/list -mcpi auth/clear https://example.com/mcp -mcpi auth/clear --all --yes -mcpi tools/list -mcpi tools/call echo message:=hi -mcpi tools/call echo '{"message":"hi"}' -mcpi @test-stdio resources/list -mcpi logging/tail # long-lived; Ctrl-C to stop -mcpi sessions/list -mcpi disconnect --session test-stdio -mcpi daemon status -mcpi daemon stop +mcpdo servers/list --config path/to/mcp.json +mcpdo servers/show test-stdio --config path/to/mcp.json +mcpdo connect test-stdio --config path/to/mcp.json +mcpdo connect my-http --config path/to/mcp.json --relogin # ignore stored OAuth; login only if auth required +mcpdo auth/list +mcpdo auth/clear https://example.com/mcp +mcpdo auth/clear --all --yes +mcpdo tools/list +mcpdo tools/call echo message:=hi +mcpdo tools/call echo '{"message":"hi"}' +mcpdo @test-stdio resources/list +mcpdo logging/tail # long-lived; Ctrl-C to stop +mcpdo connections/list +mcpdo disconnect --connection test-stdio +mcpdo daemon status +mcpdo daemon stop # Optional: private daemon for this shell only -eval "$(mcpi private)" -mcpi connect test-stdio --config path/to/mcp.json -mcpi tools/list +eval "$(mcpdo private)" +mcpdo connect test-stdio --config path/to/mcp.json +mcpdo tools/list ``` -**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. +**Globals (before subcommand):** `--format text|json`, `--plain`, `--connection ` (shorthand: `--conn`), `--catalog` / `--config`, `--stored-auth-only`. **Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope. -**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-session step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). +**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. @@ -89,11 +98,11 @@ See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as- The daemon's token controls **who can command the daemon**, not **what a spawned server can do**: a stdio MCP server runs with your full user privileges, like in any MCP host. To isolate a server you don't fully trust, -wrap the stdio command in a container — this works today with no mcpi +wrap the stdio command in a container — this works today with no mcpdo support: ```bash -mcpi connect docker run -i --rm --network none -v "$PWD:/work:ro" +mcpdo connect docker run -i --rm --network none -v "$PWD:/work:ro" ``` Tighten or loosen the flags per server (drop `--network none` if it needs @@ -102,10 +111,10 @@ local code, so they need no process isolation. ## Protocol era support -mcpi shares `core`'s `InspectorClient`, so it negotiates whichever era +mcpdo shares `core`'s `InspectorClient`, so it negotiates whichever era (`legacy` 2025-03-26-style vs. `modern`/2026-era, e.g. task-augmented calls, `server/discover`) the target actually speaks — no extra flags needed for -that to work. Two things are mcpi-specific: +that to work. Two things are mcp-conn-specific: - **`--era ` on `connect`**: `legacy` (default), `auto` (probe via `server/discover` before connecting), or `modern`. Overrides whatever a @@ -113,19 +122,19 @@ that to work. Two things are mcpi-specific: for an ad-hoc target (no config entry to read one from). ```bash - mcpi connect my-modern-server --config path/to/mcp.json --era modern - mcpi connect https://example.com/mcp --era auto + mcpdo connect my-modern-server --config path/to/mcp.json --era modern + mcpdo connect https://example.com/mcp --era auto ``` -- **Era visibility in session output**: `sessions/list`, `sessions/use`, and +- **Era visibility in connection output**: `connections/list`, `connections/use`, and `connect` all show the negotiated era inline (`@name (MRU) — server -[modern]`). `sessions/show ` gives the full picture — era, negotiated +[modern]`). `connections/show ` gives the full picture — era, negotiated protocol version, server info, capabilities, and (when the connect probed `server/discover`) the server's supported-versions list: ``` - $ mcpi sessions/show my-modern-server - Session: my-modern-server + $ mcpdo connections/show my-modern-server + Connection: my-modern-server Server: https://example.com/mcp Era: modern (2026-06-18) Supported versions: 2025-03-26, 2026-06-18 @@ -136,19 +145,19 @@ A paused modern (SEP-2663) task — one whose `tasks/get` shows `status: "input_required"` — can be resumed with `tasks/update`: ```bash -mcpi tasks/update --input-responses '{"":{"approved":true}}' +mcpdo tasks/update --input-responses '{"":{"approved":true}}' ``` ## Elicitation support -mcpi can prompt interactively for both elicitation delivery mechanisms — +mcpdo can prompt interactively for both elicitation delivery mechanisms — legacy server→client `elicitation/create` requests and modern non-task MRTR (multi-round tool response) rounds — and both modes a server may ask for: -- **URL mode**: mcpi prints the URL and waits for you to confirm you've +- **URL mode**: mcpdo prints the URL and waits for you to confirm you've finished out-of-band (there's no "decline", only accept-that-you-finished or cancel — the actual completion can't be observed locally). -- **Form mode**: mcpi renders one prompt per field from the schema, with a +- **Form mode**: mcpdo renders one prompt per field from the schema, with a review step (edit any field again, or submit) before answering. Only `--format json` callers get an automatic decline (URL mode: cancel) @@ -157,7 +166,7 @@ instead of a prompt. > **Decision — who answers a prompt.** Only `--format json` auto-declines > (its stdout must stay a single machine-readable payload). Everything else — > including a plain non-TTY stdin — gets a real prompt, which means an agent -> driving mcpi can routinely read a form-mode question and answer on the +> driving mcpdo can routinely read a form-mode question and answer on the > user's behalf. That is deliberate for an inspector tool. URL-mode is > different: there is never an auto-accept — completion is only ever > confirmed by an explicit answer to the prompt, because the out-of-band @@ -165,12 +174,12 @@ instead of a prompt. > perform. Use `--elicit off` on `connect` to keep any elicitation from > being asked at all. -By default mcpi advertises **both** modes to the server (`elicit: {url, +By default mcpdo advertises **both** modes to the server (`elicit: {url, form}`), matching pre-#1783 behavior. Override this per connection with `--elicit ` on `connect`: - `off` — advertise no elicitation capability at all. Useful when whatever is - driving mcpi (a script, an agent) can't handle an interactive prompt itself + driving mcpdo (a script, an agent) can't handle an interactive prompt itself — omitting the capability lets a well-behaved server fall back to its own alternative (e.g. proceeding with defaults) instead of the request being auto-declined. @@ -183,16 +192,16 @@ Like `--era`, this overrides whatever a catalog/config entry's (no config entry to read one from): ```bash -mcpi connect my-server --config path/to/mcp.json --elicit off -mcpi connect https://example.com/mcp --elicit url +mcpdo connect my-server --config path/to/mcp.json --elicit off +mcpdo connect https://example.com/mcp --elicit url ``` ## Relation to one-shot CLI -| | One-shot | Session (`mcpi`) | +| | One-shot | Connection (`mcpdo`) | | ------------- | ------------------------------------- | ------------------------------- | -| Entrypoint | `mcp-inspector --cli` | `mcpi` | -| Package (dev) | `clients/cli` | `clients/mcpi` | +| Entrypoint | `mcp-inspector --cli` | `mcpdo` | +| Package (dev) | `clients/cli` | `clients/daemon-cli` | | Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands | One-shot docs: [`clients/cli/README.md`](../cli/README.md). diff --git a/clients/mcpi/__tests__/agent-help.test.ts b/clients/daemon-cli/__tests__/agent-help.test.ts similarity index 62% rename from clients/mcpi/__tests__/agent-help.test.ts rename to clients/daemon-cli/__tests__/agent-help.test.ts index 89f5fb5978..f2d4ba4c9d 100644 --- a/clients/mcpi/__tests__/agent-help.test.ts +++ b/clients/daemon-cli/__tests__/agent-help.test.ts @@ -2,19 +2,19 @@ import { describe, it, expect } from "vitest"; import { existsSync } from "node:fs"; import { runMcp } from "./helpers/mcp-runner.js"; -describe("mcpi agent-help", () => { - it("prints skills/mcpi/SKILL.md content, including its frontmatter", async () => { +describe("mcpdo agent-help", () => { + it("prints skills/mcpdo/SKILL.md content, including its frontmatter", async () => { const result = await runMcp(["agent-help"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name: mcpi"); - expect(result.stdout).toContain("mcpi connect"); + expect(result.stdout).toContain("name: mcpdo"); + expect(result.stdout).toContain("mcpdo connect"); }); it("--path prints the resolved SKILL.md file path", async () => { const result = await runMcp(["agent-help", "--path"]); expect(result.exitCode).toBe(0); const printedPath = result.stdout.trim(); - expect(printedPath.endsWith("skills/mcpi/SKILL.md")).toBe(true); + expect(printedPath.endsWith("skills/mcpdo/SKILL.md")).toBe(true); expect(existsSync(printedPath)).toBe(true); }); }); diff --git a/clients/mcpi/__tests__/authorize.test.ts b/clients/daemon-cli/__tests__/authorize.test.ts similarity index 85% rename from clients/mcpi/__tests__/authorize.test.ts rename to clients/daemon-cli/__tests__/authorize.test.ts index 7c7c54d07e..a65a2a8146 100644 --- a/clients/mcpi/__tests__/authorize.test.ts +++ b/clients/daemon-cli/__tests__/authorize.test.ts @@ -41,7 +41,8 @@ describe("authorizeInFrontend", () => { }); it("no-ops for non-OAuth-capable (stdio) configs", async () => { - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await authorizeInFrontend( { type: "stdio", command: "x" } as MCPServerConfig, undefined, @@ -51,7 +52,8 @@ describe("authorizeInFrontend", () => { it("runs connectInspectorWithOAuth for HTTP configs", async () => { connectSpy.mockResolvedValue(undefined); - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await authorizeInFrontend( { type: "streamable-http", url: "https://example.com/mcp" }, { protocolEra: "2025-11-25" } as never, @@ -64,7 +66,8 @@ describe("authorizeInFrontend", () => { it("swallows disconnect failures in finally", async () => { connectSpy.mockResolvedValue(undefined); disconnectSpy.mockRejectedValueOnce(new Error("bye")); - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await expect( authorizeInFrontend( { type: "streamable-http", url: "https://example.com/mcp" }, @@ -75,7 +78,8 @@ describe("authorizeInFrontend", () => { it("always admits interactive OAuth (isTTY: true), regardless of the real TTY state", async () => { connectSpy.mockResolvedValue(undefined); - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await authorizeInFrontend( { type: "streamable-http", url: "https://example.com/mcp" }, undefined, @@ -86,7 +90,8 @@ describe("authorizeInFrontend", () => { it("addresses the printed authorization line to whoever must relay it — a human directly, or an agent on behalf of one", async () => { connectSpy.mockResolvedValue(undefined); - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await authorizeInFrontend( { type: "streamable-http", url: "https://example.com/mcp" }, undefined, @@ -102,11 +107,12 @@ describe("authorizeInFrontend", () => { ); }); - it("maps EmaClientNotConfiguredError to actionable mcpi guidance", async () => { + it("maps EmaClientNotConfiguredError to actionable mcpdo guidance", async () => { const { EmaClientNotConfiguredError } = await import("@inspector/core/auth/ema/clientConfigError.js"); connectSpy.mockRejectedValue(new EmaClientNotConfiguredError("disabled")); - const { authorizeInFrontend } = await import("../src/session/authorize.js"); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); await expect( authorizeInFrontend( { type: "streamable-http", url: "https://example.com/mcp" }, diff --git a/clients/mcpi/__tests__/session-stored-auth.test.ts b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts similarity index 98% rename from clients/mcpi/__tests__/session-stored-auth.test.ts rename to clients/daemon-cli/__tests__/connection-stored-auth.test.ts index 11c918d10b..708e55cc37 100644 --- a/clients/mcpi/__tests__/session-stored-auth.test.ts +++ b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts @@ -9,7 +9,7 @@ import { clearStoredAuthForRelogin, listStoredAuth, resolveStoredAuthKey, -} from "../src/session/stored-auth.js"; +} from "../src/connection/stored-auth.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; import { @@ -56,7 +56,7 @@ function writeOAuthFixture(dir: string): string { return file; } -describe("session stored-auth helpers", () => { +describe("connection stored-auth helpers", () => { let dir: string | undefined; let prevPath: string | undefined; @@ -185,7 +185,7 @@ describe("mcp auth/list and auth/clear", () => { } }); - it("lists and clears via session commands", async () => { + it("lists and clears via connection commands", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); const file = writeOAuthFixture(dir); resetNodeOAuthStorageCache(); diff --git a/clients/mcpi/__tests__/daemon-sessions.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts similarity index 86% rename from clients/mcpi/__tests__/daemon-sessions.test.ts rename to clients/daemon-cli/__tests__/daemon-connections.test.ts index b6937823ae..a43b37fd92 100644 --- a/clients/mcpi/__tests__/daemon-sessions.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -9,11 +9,11 @@ import { parseRequestLine, encodeResponse } from "../src/daemon/framing.js"; import { DEFAULT_IDLE_MS, elicitCapabilityToClientOption, - getLiveSessionAuthInfo, - getSessionAuthInfo, - isSessionAuthRequiredError, - SessionRegistry, -} from "../src/daemon/sessions.js"; + getLiveConnectionAuthInfo, + getConnectionAuthInfo, + isConnectionAuthRequiredError, + ConnectionRegistry, +} from "../src/daemon/connections.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; @@ -52,60 +52,62 @@ describe("elicitCapabilityToClientOption", () => { }); }); -describe("isSessionAuthRequiredError", () => { +describe("isConnectionAuthRequiredError", () => { it("treats EMA client misconfiguration as auth_required (front-end maps it to guidance)", async () => { const { EmaClientNotConfiguredError } = await import("@inspector/core/auth/ema/clientConfigError.js"); expect( - isSessionAuthRequiredError( + isConnectionAuthRequiredError( new EmaClientNotConfiguredError("not_configured"), ), ).toBe(true); }); it("recognizes unauthorized, recovery, and SDK token-exchange failures", () => { - expect(isSessionAuthRequiredError(new Error("nope"))).toBe(false); + expect(isConnectionAuthRequiredError(new Error("nope"))).toBe(false); expect( - isSessionAuthRequiredError( + isConnectionAuthRequiredError( new AuthRecoveryRequiredError(new URL("https://as.example/a"), { reason: "unauthorized", }), ), ).toBe(true); const unauthorized = Object.assign(new Error("boom"), { status: 401 }); - expect(isSessionAuthRequiredError(unauthorized)).toBe(true); + expect(isConnectionAuthRequiredError(unauthorized)).toBe(true); expect( - isSessionAuthRequiredError( + isConnectionAuthRequiredError( new Error( "Either provider.prepareTokenRequest() or authorizationCode is required", ), ), ).toBe(true); expect( - isSessionAuthRequiredError( + isConnectionAuthRequiredError( new Error("redirectUrl is required for authorization_code flow"), ), ).toBe(true); expect( - isSessionAuthRequiredError( - new Error("No code verifier saved for session"), + isConnectionAuthRequiredError( + new Error("No code verifier saved for connection"), ), ).toBe(true); }); }); -describe("getSessionAuthInfo", () => { +describe("getConnectionAuthInfo", () => { const clientWith = ( getOAuthState: () => Promise, - ): Parameters[0] => - ({ getOAuthState }) as unknown as Parameters[0]; + ): Parameters[0] => + ({ getOAuthState }) as unknown as Parameters< + typeof getConnectionAuthInfo + >[0]; - it("is undefined for no-auth sessions and when the state read fails", async () => { + it("is undefined for no-auth connections and when the state read fails", async () => { expect( - await getSessionAuthInfo(clientWith(async () => undefined)), + await getConnectionAuthInfo(clientWith(async () => undefined)), ).toBeUndefined(); expect( - await getSessionAuthInfo( + await getConnectionAuthInfo( clientWith(async () => { throw new Error("storage unavailable"); }), @@ -115,7 +117,7 @@ describe("getSessionAuthInfo", () => { it("projects standard OAuth state (scope + clientId when present)", async () => { expect( - await getSessionAuthInfo( + await getConnectionAuthInfo( clientWith(async () => ({ authorized: true, protocol: "standard", @@ -134,7 +136,7 @@ describe("getSessionAuthInfo", () => { it("projects EMA state with IdP session and omits absent optionals", async () => { expect( - await getSessionAuthInfo( + await getConnectionAuthInfo( clientWith(async () => ({ authorized: false, protocol: "ema", @@ -150,11 +152,11 @@ describe("getSessionAuthInfo", () => { }); }); -describe("getLiveSessionAuthInfo", () => { +describe("getLiveConnectionAuthInfo", () => { it("is undefined for stdio, malformed http configs, and unengaged OAuth", async () => { const { resetNodeOAuthStorageCache } = await import("@inspector/core/auth/node/storage-node.js"); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-live-auth-")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-live-auth-")); const saved = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; const savedClient = process.env.MCP_CLIENT_CONFIG_PATH; process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(dir, "oauth.json"); @@ -162,19 +164,19 @@ describe("getLiveSessionAuthInfo", () => { resetNodeOAuthStorageCache(); try { expect( - await getLiveSessionAuthInfo({ + await getLiveConnectionAuthInfo({ serverConfig: { type: "stdio", command: "x" }, }), ).toBeUndefined(); // Defensive: OAuth-capable type without a usable url. expect( - await getLiveSessionAuthInfo({ + await getLiveConnectionAuthInfo({ serverConfig: { type: "streamable-http" } as never, }), ).toBeUndefined(); // http server, no oauth config anywhere, empty storage: no snapshot. expect( - await getLiveSessionAuthInfo({ + await getLiveConnectionAuthInfo({ serverConfig: { type: "streamable-http", url: "https://mcp.example.com/mcp", @@ -182,11 +184,11 @@ describe("getLiveSessionAuthInfo", () => { }), ).toBeUndefined(); // Corrupt oauth.json: the disk read fails, and the best-effort catch - // yields undefined rather than failing sessions/show. + // yields undefined rather than failing connections/show. fs.writeFileSync(process.env.MCP_INSPECTOR_OAUTH_STATE_PATH!, "{nope"); resetNodeOAuthStorageCache(); expect( - await getLiveSessionAuthInfo({ + await getLiveConnectionAuthInfo({ serverConfig: { type: "streamable-http", url: "https://mcp.example.com/mcp", @@ -205,18 +207,18 @@ describe("getLiveSessionAuthInfo", () => { }); }); -describe("SessionRegistry", () => { - it("requires an explicit session when asked", () => { - const registry = new SessionRegistry(0); +describe("ConnectionRegistry", () => { + it("requires an explicit connection when asked", () => { + const registry = new ConnectionRegistry(0); expect(() => registry.resolve(undefined, true)).toThrow(CliExitCodeError); expect(() => registry.resolve(undefined, false)).toThrow( - /No open sessions/, + /No open connections/, ); }); it("tracks MRU across connect/disconnect", async () => { const { command, args } = getTestMcpServerCommand(); - const registry = new SessionRegistry(0); + const registry = new ConnectionRegistry(0); const a = await registry.connect({ name: "a", serverConfig: { type: "stdio", command, args }, @@ -237,17 +239,19 @@ describe("SessionRegistry", () => { await registry.disconnect("b", false); expect(registry.list().map((s) => s.name)).toEqual(["a"]); await registry.disconnect(undefined, false); - expect(registry.sessionCount()).toBe(0); + expect(registry.connectionCount()).toBe(0); expect(DEFAULT_IDLE_MS).toBe(60_000); }); - it("reports the connect-time auth snapshot, and sessions/show recomputes from disk", async () => { + it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const { NodeOAuthStorage, resetNodeOAuthStorageCache } = await import("@inspector/core/auth/node/storage-node.js"); // Isolated client.json (EMA IdP config) + oauth.json so the show // handler's disk read is deterministic. - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-auth-info-")); + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mcp-conn-auth-info-"), + ); const savedEnv = { MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, MCP_INSPECTOR_OAUTH_STATE_PATH: @@ -295,7 +299,7 @@ describe("SessionRegistry", () => { .spyOn(InspectorClient.prototype, "connect") .mockResolvedValue(undefined); const server = new DaemonServer({ - dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-auth-daemon-")), + dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-auth-daemon-")), idleMs: 0, }); const registry = server.registry; @@ -330,7 +334,7 @@ describe("SessionRegistry", () => { expect(registry.list()[0]?.auth).toEqual(expected); expect(registry.use("a").auth).toEqual(expected); - // sessions/show reads *disk*, not the client's memory-cached storage: + // connections/show reads *disk*, not the client's memory-cached storage: // seed an IdP session on disk and expect logged_in (no tokens were // persisted, so authorized is false — matching auth/ema-status). await new NodeOAuthStorage().saveIdpSession(issuer, { @@ -339,7 +343,7 @@ describe("SessionRegistry", () => { }); const shown = await server.handle({ id: "show", - op: "sessions/show", + op: "connections/show", params: { name: "a" }, }); expect(shown.ok).toBe(true); @@ -358,7 +362,7 @@ describe("SessionRegistry", () => { expect(registry.list()[0]?.auth).toEqual(expected); const loggedOut = await server.handle({ id: "show2", - op: "sessions/show", + op: "connections/show", params: { name: "a" }, }); expect(loggedOut.ok).toBe(true); @@ -397,7 +401,7 @@ describe("DaemonServer IPC", () => { } }); - it("serves ping / connect / sessions/list / disconnect over the socket", async () => { + it("serves ping / connect / connections/list / disconnect over the socket", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-")); server = new DaemonServer({ dir, idleMs: 0 }); await server.start(); @@ -422,12 +426,12 @@ describe("DaemonServer IPC", () => { expect(connected.name).toBe("stdio"); expect(connected.isMru).toBe(true); - const listed = await callDaemon<{ sessions: { name: string }[] }>( - "sessions/list", + const listed = await callDaemon<{ connections: { name: string }[] }>( + "connections/list", {}, { socketPath: server.socketPath }, ); - expect(listed.sessions.map((s) => s.name)).toEqual(["stdio"]); + expect(listed.connections.map((s) => s.name)).toEqual(["stdio"]); const status = await callDaemon<{ pid: number; socketPath: string }>( "daemon/status", @@ -445,7 +449,7 @@ describe("DaemonServer IPC", () => { expect(disc.name).toBe("stdio"); }); - it("runs rpc tools/list and initialize against a live session", async () => { + it("runs rpc tools/list and initialize against a live connection", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpc-")); server = new DaemonServer({ dir, idleMs: 0 }); await server.start(); diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts similarity index 90% rename from clients/mcpi/__tests__/daemon-coverage.test.ts rename to clients/daemon-cli/__tests__/daemon-coverage.test.ts index 4dba9ae0c7..780a6bc89c 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -11,7 +11,7 @@ import { readLogTail, resolveDaemonScriptPath, } from "../src/daemon/ensure.js"; -import { SessionRegistry } from "../src/daemon/sessions.js"; +import { ConnectionRegistry } from "../src/daemon/connections.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; import { @@ -48,7 +48,7 @@ describe("daemon coverage", () => { return dir; } - it("handle() covers invalid connect / sessions/use / unknown op", async () => { + it("handle() covers invalid connect / connections/use / unknown op", async () => { server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); const badConnect = await server.handle({ id: "1", @@ -60,15 +60,15 @@ describe("daemon coverage", () => { const badUse = await server.handle({ id: "2", - op: "sessions/use", + op: "connections/use", params: {}, }); expect(badUse.ok).toBe(false); - // sessions/show with no `params` at all exercises the `request.params ?? - // {}` fallback; with no active session it still fails, same shape as - // sessions/use above. - const badShow = await server.handle({ id: "2b", op: "sessions/show" }); + // connections/show with no `params` at all exercises the `request.params ?? + // {}` fallback; with no active connection it still fails, same shape as + // connections/use above. + const badShow = await server.handle({ id: "2b", op: "connections/show" }); expect(badShow.ok).toBe(false); const unknown = await server.handle({ @@ -83,14 +83,14 @@ describe("daemon coverage", () => { vi.spyOn(server.registry, "list").mockImplementationOnce(() => { throw bare; }); - const listed = await server.handle({ id: "4", op: "sessions/list" }); + const listed = await server.handle({ id: "4", op: "connections/list" }); expect(listed.ok).toBe(false); if (!listed.ok) expect(listed.error.code).toBe("cli_error"); vi.spyOn(server.registry, "list").mockImplementationOnce(() => { throw new Error("boom"); }); - const boom = await server.handle({ id: "5", op: "sessions/list" }); + const boom = await server.handle({ id: "5", op: "connections/list" }); expect(boom.ok).toBe(false); // Non-CliExitCodeError failures go through classifyError (code "error"). if (!boom.ok) expect(boom.error.code).toBe("error"); @@ -98,7 +98,7 @@ describe("daemon coverage", () => { vi.spyOn(server.registry, "list").mockImplementationOnce(() => { throw "string-throw"; }); - const strErr = await server.handle({ id: "6", op: "sessions/list" }); + const strErr = await server.handle({ id: "6", op: "connections/list" }); expect(strErr.ok).toBe(false); const disc = await server.handle({ @@ -203,8 +203,8 @@ describe("daemon coverage", () => { server = new DaemonServer({ dir: d, idleMs: 0 }); await server.start(); await expect( - callDaemon("sessions/use", {}, { socketPath: server.socketPath }), - ).rejects.toThrow(/requires a session name/); + callDaemon("connections/use", {}, { socketPath: server.socketPath }), + ).rejects.toThrow(/requires a connection name/); }); it("callDaemon rejects malformed response JSON", async () => { @@ -328,9 +328,9 @@ describe("daemon coverage", () => { } }, 5000); - it("sessions/use and reconnect replace an existing session", async () => { + it("connections/use and reconnect replace an existing connection", async () => { const { command, args } = getTestMcpServerCommand(); - const registry = new SessionRegistry(0); + const registry = new ConnectionRegistry(0); await registry.connect({ name: "s", serverConfig: { type: "stdio", command, args }, @@ -347,7 +347,7 @@ describe("daemon coverage", () => { }); it("idle handler fires after last disconnect when idleMs > 0", async () => { - const registry = new SessionRegistry(20); + const registry = new ConnectionRegistry(20); let idle = false; registry.setIdleHandler(() => { idle = true; @@ -366,7 +366,7 @@ describe("daemon coverage", () => { it("covers touch/auth/oauth-setup/disconnect-swallow/reconnect-before-idle", async () => { const { command, args } = getTestMcpServerCommand(); - const registry = new SessionRegistry(0); + const registry = new ConnectionRegistry(0); registry.touch("missing"); await registry.connect({ @@ -374,8 +374,8 @@ describe("daemon coverage", () => { serverConfig: { type: "stdio", command, args }, serverIdentity: "s", }); - const session = registry.resolve("s", false); - vi.spyOn(session.client, "disconnect").mockRejectedValueOnce( + const connection = registry.resolve("s", false); + vi.spyOn(connection.client, "disconnect").mockRejectedValueOnce( new Error("teardown boom"), ); await expect(registry.disconnect("s", false)).resolves.toEqual({ @@ -431,7 +431,7 @@ describe("daemon coverage", () => { }), ).rejects.toThrow(); - const idleReg = new SessionRegistry(80); + const idleReg = new ConnectionRegistry(80); const onIdle = vi.fn(); idleReg.setIdleHandler(onIdle); await idleReg.connect({ @@ -559,7 +559,7 @@ describe("daemon coverage", () => { expect(pong).toBeDefined(); }); - it("session-less start arms idle and self-reaps", async () => { + it("connection-less start arms idle and self-reaps", async () => { const d = freshDir(); let shut = false; server = new DaemonServer({ @@ -570,7 +570,7 @@ describe("daemon coverage", () => { }, }); await server.start(); - // ensureDaemon from tools/list with no sessions must not leak forever. + // ensureDaemon from tools/list with no connections must not leak forever. expect(server.registry.idleRemainingMs()).not.toBeNull(); await new Promise((r) => setTimeout(r, 100)); expect(shut).toBe(true); @@ -578,7 +578,7 @@ describe("daemon coverage", () => { }); it("connect failure for a dead stdio command is surfaced and re-arms idle", async () => { - const registry = new SessionRegistry(5_000); + const registry = new ConnectionRegistry(5_000); let idle = false; registry.setIdleHandler(() => { idle = true; @@ -598,8 +598,8 @@ describe("daemon coverage", () => { expect(idle).toBe(false); }); - it("re-arms idle when createSessionClient fails before client.connect", async () => { - const registry = new SessionRegistry(5_000); + it("re-arms idle when createConnectionClient fails before client.connect", async () => { + const registry = new ConnectionRegistry(5_000); registry.setIdleHandler(() => {}); const prev = process.env.MCP_OAUTH_CALLBACK_URL; process.env.MCP_OAUTH_CALLBACK_URL = "https://example.com/oauth/callback"; @@ -646,7 +646,7 @@ describe("daemon coverage", () => { } }); - it("sessions/use via handle and blank IPC lines", async () => { + it("connections/use via handle and blank IPC lines", async () => { const d = freshDir(); server = new DaemonServer({ dir: d, idleMs: 60_000 }); await server.start(); @@ -662,18 +662,18 @@ describe("daemon coverage", () => { ); const used = await server.handle({ id: "u", - op: "sessions/use", + op: "connections/use", params: { name: "s" }, }); expect(used.ok).toBe(true); expect(server.registry.idleRemainingMs()).toBeNull(); - // sessions/show over the same live session — exercises the full case + // connections/show over the same live connection — exercises the full case // body (serverInfo/protocolVersion/protocolEra/capabilities lookups) // in-process, where coverage instrumentation can see it. const shown = await server.handle({ id: "s2", - op: "sessions/show", + op: "connections/show", params: { name: "s" }, }); expect(shown.ok).toBe(true); @@ -705,7 +705,7 @@ describe("daemon coverage", () => { }); }); -describe("mcp session coverage", () => { +describe("mcp connection coverage", () => { let configPath: string | undefined; let storageDir: string | undefined; @@ -737,11 +737,11 @@ describe("mcp session coverage", () => { return { MCP_STORAGE_DIR: storageDir, MCP_INSPECTOR_DAEMON_DIR: storageDir, - MCP_ALLOW_DEFAULT_SESSION: "1", + MCP_ALLOW_DEFAULT_CONNECTION: "1", }; } - it("covers sessions/use, daemon status, @session connect, and stop no-op", async () => { + it("covers connections/use, daemon status, @connection connect, and stop no-op", async () => { configPath = createSampleTestConfig(); const e = env(); @@ -766,16 +766,19 @@ describe("mcp session coverage", () => { expectCliSuccess(connected); expect(JSON.parse(connected.stdout).name).toBe("alpha"); - const used = await runMcp(["sessions/use", "@alpha", "--format", "text"], { - env: e, - }); + const used = await runMcp( + ["connections/use", "@alpha", "--format", "text"], + { + env: e, + }, + ); expectCliSuccess(used); expect(used.stdout).toContain("alpha"); const status = await runMcp(["daemon", "status"], { env: e }); expectCliSuccess(status); - const listed = await runMcp(["sessions/list"], { env: e }); + const listed = await runMcp(["connections/list"], { env: e }); expectCliSuccess(listed); const viaServer = await runMcp( @@ -785,7 +788,7 @@ describe("mcp session coverage", () => { "test-stdio", "--config", configPath, - "--session", + "--connection", "via-flag", "--format", "json", @@ -822,7 +825,7 @@ describe("mcp session coverage", () => { ); expectCliFailure(badTimeout); - const emptyUse = await runMcp(["sessions/use", ""], { env: e }); + const emptyUse = await runMcp(["connections/use", ""], { env: e }); expectCliFailure(emptyUse); }); @@ -833,7 +836,7 @@ describe("mcp session coverage", () => { const result = await runMcp( [ "connect", - "--session", + "--connection", "adhoc", "--transport", "stdio", @@ -854,7 +857,7 @@ describe("mcp session coverage", () => { [ "connect", "http://127.0.0.1:9/mcp", - "--session", + "--connection", "url", "--connect-timeout", "100", @@ -867,13 +870,13 @@ describe("mcp session coverage", () => { expectCliFailure(result); }); - it("requires explicit session in non-interactive mode without opt-in", async () => { + it("requires explicit connection in non-interactive mode without opt-in", async () => { configPath = createSampleTestConfig(); storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-")); const e = { MCP_STORAGE_DIR: storageDir, MCP_INSPECTOR_DAEMON_DIR: storageDir, - // no MCP_ALLOW_DEFAULT_SESSION + // no MCP_ALLOW_DEFAULT_CONNECTION }; const connected = await runMcp( ["connect", "test-stdio", "--config", configPath, "--format", "json"], @@ -884,8 +887,8 @@ describe("mcp session coverage", () => { // Force requireExplicit by stubbing isTTY false is default in vitest forks. const disc = await runMcp(["disconnect", "--format", "json"], { env: e }); expectCliFailure(disc); - expect(disc.stderr).toMatch(/Explicit|--session|non-interactive/i); + expect(disc.stderr).toMatch(/Explicit|--connection|non-interactive/i); - await runMcp(["disconnect", "--session", "test-stdio"], { env: e }); + await runMcp(["disconnect", "--connection", "test-stdio"], { env: e }); }); }); diff --git a/clients/mcpi/__tests__/daemon-ipc-glue.test.ts b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts similarity index 100% rename from clients/mcpi/__tests__/daemon-ipc-glue.test.ts rename to clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/daemon-cli/__tests__/daemon-paths.test.ts similarity index 94% rename from clients/mcpi/__tests__/daemon-paths.test.ts rename to clients/daemon-cli/__tests__/daemon-paths.test.ts index e81517ec9b..02bcbed491 100644 --- a/clients/mcpi/__tests__/daemon-paths.test.ts +++ b/clients/daemon-cli/__tests__/daemon-paths.test.ts @@ -65,14 +65,14 @@ describe("daemon paths", () => { }); it("createPrivateDaemonDir nests under a short 0700 tmpdir layout", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-t-")); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-t-")); setEnv("TMPDIR", tmp + path.sep); const dir = createPrivateDaemonDir(); - // $TMPDIR/mcpi-/<8-hex>; short enough that daemon.sock stays inside + // $TMPDIR/mcp-conn-/<8-hex>; short enough that daemon.sock stays inside // the platform sun_path limit even for macOS /var/folders tmpdirs. expect(dir.startsWith(tmp)).toBe(true); expect(path.basename(dir)).toMatch(/^[0-9a-f]{8}$/); - expect(path.basename(path.dirname(dir))).toMatch(/^mcpi-/); + expect(path.basename(path.dirname(dir))).toMatch(/^mcp-conn-/); expect(fs.statSync(dir).isDirectory()).toBe(true); if (process.platform !== "win32") { expect(fs.statSync(dir).mode & 0o777).toBe(0o700); diff --git a/clients/mcpi/__tests__/daemon-private.test.ts b/clients/daemon-cli/__tests__/daemon-private.test.ts similarity index 96% rename from clients/mcpi/__tests__/daemon-private.test.ts rename to clients/daemon-cli/__tests__/daemon-private.test.ts index b4cc07ef0d..d46ec66849 100644 --- a/clients/mcpi/__tests__/daemon-private.test.ts +++ b/clients/daemon-cli/__tests__/daemon-private.test.ts @@ -27,7 +27,7 @@ import { import { createPrivateBinding, formatPrivateEnvExports, -} from "../src/session/private-env.js"; +} from "../src/connection/private-env.js"; describe("daemon IPC token", () => { it("compares tokens in constant time", () => { @@ -48,7 +48,7 @@ describe("daemon IPC token", () => { }); }); -describe("mcpi private", () => { +describe("mcpdo private", () => { let home: string | undefined; let prevHome: string | undefined; @@ -75,7 +75,9 @@ describe("mcpi private", () => { }); expectCliSuccess(result); expect(result.stdout).toMatch( - new RegExp(`export ${DAEMON_DIR_ENV}='[^']+/mcpi-[^/']+/[0-9a-f]{8}'`), + new RegExp( + `export ${DAEMON_DIR_ENV}='[^']+/mcp-conn-[^/']+/[0-9a-f]{8}'`, + ), ); expect(result.stdout).toMatch( new RegExp(`export ${DAEMON_TOKEN_ENV}='[^']+'`), @@ -100,7 +102,7 @@ describe("mcpi private", () => { useTempHome(); const binding = createPrivateBinding(); expect(path.basename(binding.dir)).toMatch(/^[0-9a-f]{8}$/); - expect(path.basename(path.dirname(binding.dir))).toMatch(/^mcpi-/); + expect(path.basename(path.dirname(binding.dir))).toMatch(/^mcp-conn-/); expect(binding.dir.startsWith(os.tmpdir())).toBe(true); expect(binding.token.length).toBeGreaterThan(20); }); @@ -192,7 +194,7 @@ describe("private daemon end-to-end", () => { expect(closed).toBe(true); }); - it("session front-end rethrows non-unreachable daemon errors", async () => { + it("connection front-end rethrows non-unreachable daemon errors", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-")); const token = "good-token"; server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); @@ -204,7 +206,7 @@ describe("private daemon end-to-end", () => { [DAEMON_TOKEN_ENV]: "wrong-token", }; - const listed = await runMcp(["sessions/list"], { env }); + const listed = await runMcp(["connections/list"], { env }); expectCliFailure(listed); expect(listed.stderr).toMatch(/authentication failed|daemon_auth_failed/i); @@ -216,7 +218,7 @@ describe("private daemon end-to-end", () => { const servers = await runMcp(["servers/list", "--config", configPath], { env, }); - // Optional daemon probe must not swallow auth failures as empty sessions. + // Optional daemon probe must not swallow auth failures as empty connections. expectCliFailure(servers); } finally { deleteConfigFile(configPath); diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts similarity index 100% rename from clients/mcpi/__tests__/daemon-stream.test.ts rename to clients/daemon-cli/__tests__/daemon-stream.test.ts diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts similarity index 75% rename from clients/mcpi/__tests__/dispatch.test.ts rename to clients/daemon-cli/__tests__/dispatch.test.ts index a037270757..c7e7662b13 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -11,11 +11,11 @@ vi.mock("../src/daemon/index.js", () => ({ streamDaemon: (...args: unknown[]) => streamDaemon(...args), })); -vi.mock("../src/session/elicitation-prompt.js", () => ({ +vi.mock("../src/connection/elicitation-prompt.js", () => ({ promptElicitation: (...args: unknown[]) => promptElicitation(...args), })); -describe("dispatchSessionRpc", () => { +describe("dispatchConnectionRpc", () => { let stdout: string; let originalWrite: typeof process.stdout.write; @@ -45,8 +45,9 @@ describe("dispatchSessionRpc", () => { kind: "result", result: { tools: [] }, }); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/list", {}, { format: "json", requireExplicit: false }, @@ -62,8 +63,9 @@ describe("dispatchSessionRpc", () => { tools: [{ name: "echo", description: "Echo", inputSchema: {} }], }, }); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc("tools/list", {}, { requireExplicit: false }); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc("tools/list", {}, { requireExplicit: false }); expect(stdout).toContain("Tools (1):"); expect(stdout).toContain("`echo"); }); @@ -73,8 +75,9 @@ describe("dispatchSessionRpc", () => { kind: "ndjson", lines: [{ hasApp: false, toolName: "a" }], }); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/list", { appInfo: true }, { requireExplicit: false }, @@ -97,11 +100,12 @@ describe("dispatchSessionRpc", () => { expect(opts.signal?.aborted).toBe(true); }, ); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "logging/tail", {}, - { requireExplicit: false, session: "@s" }, + { requireExplicit: false, connection: "@s" }, ); expect(stdout).toContain("Subscribed:"); expect(streamDaemon).toHaveBeenCalled(); @@ -115,8 +119,9 @@ describe("dispatchSessionRpc", () => { return { kind: "result", result: {} }; }, ); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/call", {}, { format: "json", requireExplicit: false }, @@ -127,8 +132,9 @@ describe("dispatchSessionRpc", () => { it("removes the SIGINT/SIGTERM listeners after the rpc call settles", async () => { callDaemon.mockResolvedValue({ kind: "result", result: {} }); const before = process.listenerCount("SIGINT"); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/call", {}, { format: "json", requireExplicit: false }, @@ -149,8 +155,9 @@ describe("dispatchSessionRpc", () => { value: true, }); try { - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/call", {}, { format: "text", requireExplicit: false }, @@ -174,8 +181,9 @@ describe("dispatchSessionRpc", () => { it("wires onElicitation as non-interactive for --format json", async () => { callDaemon.mockResolvedValue({ kind: "result", result: {} }); - const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); - await dispatchSessionRpc( + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( "tools/call", {}, { format: "json", requireExplicit: false }, @@ -192,24 +200,24 @@ describe("dispatchSessionRpc", () => { }); }); -describe("hoistAtSession / stripAt / requireExplicitSession", () => { +describe("hoistAtConnection / stripAt / requireExplicitConnection", () => { it("stripAt removes leading @", async () => { - const { stripAt, requireExplicitSession } = - await import("../src/session/dispatch.js"); + const { stripAt, requireExplicitConnection } = + await import("../src/connection/dispatch.js"); expect(stripAt("@x")).toBe("x"); expect(stripAt(undefined)).toBeUndefined(); - const prev = process.env.MCP_ALLOW_DEFAULT_SESSION; - process.env.MCP_ALLOW_DEFAULT_SESSION = "1"; - expect(requireExplicitSession()).toBe(false); - if (prev === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; - else process.env.MCP_ALLOW_DEFAULT_SESSION = prev; + const prev = process.env.MCP_ALLOW_DEFAULT_CONNECTION; + process.env.MCP_ALLOW_DEFAULT_CONNECTION = "1"; + expect(requireExplicitConnection()).toBe(false); + if (prev === undefined) delete process.env.MCP_ALLOW_DEFAULT_CONNECTION; + else process.env.MCP_ALLOW_DEFAULT_CONNECTION = prev; }); - it("requireExplicitSession keys off stdin TTY (piping stdout still OK)", async () => { - const { requireExplicitSession } = - await import("../src/session/dispatch.js"); - const prevEnv = process.env.MCP_ALLOW_DEFAULT_SESSION; - delete process.env.MCP_ALLOW_DEFAULT_SESSION; + it("requireExplicitConnection keys off stdin TTY (piping stdout still OK)", async () => { + const { requireExplicitConnection } = + await import("../src/connection/dispatch.js"); + const prevEnv = process.env.MCP_ALLOW_DEFAULT_CONNECTION; + delete process.env.MCP_ALLOW_DEFAULT_CONNECTION; const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); try { @@ -221,13 +229,13 @@ describe("hoistAtSession / stripAt / requireExplicitSession", () => { configurable: true, value: false, }); - expect(requireExplicitSession()).toBe(false); + expect(requireExplicitConnection()).toBe(false); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false, }); - expect(requireExplicitSession()).toBe(true); + expect(requireExplicitConnection()).toBe(true); } finally { if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); else @@ -242,8 +250,9 @@ describe("hoistAtSession / stripAt / requireExplicitSession", () => { configurable: true, value: undefined, }); - if (prevEnv === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; - else process.env.MCP_ALLOW_DEFAULT_SESSION = prevEnv; + if (prevEnv === undefined) + delete process.env.MCP_ALLOW_DEFAULT_CONNECTION; + else process.env.MCP_ALLOW_DEFAULT_CONNECTION = prevEnv; } }); }); diff --git a/clients/mcpi/__tests__/elicitation-bridge.test.ts b/clients/daemon-cli/__tests__/elicitation-bridge.test.ts similarity index 100% rename from clients/mcpi/__tests__/elicitation-bridge.test.ts rename to clients/daemon-cli/__tests__/elicitation-bridge.test.ts diff --git a/clients/mcpi/__tests__/elicitation-client.test.ts b/clients/daemon-cli/__tests__/elicitation-client.test.ts similarity index 100% rename from clients/mcpi/__tests__/elicitation-client.test.ts rename to clients/daemon-cli/__tests__/elicitation-client.test.ts diff --git a/clients/mcpi/__tests__/elicitation-prompt.test.ts b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts similarity index 89% rename from clients/mcpi/__tests__/elicitation-prompt.test.ts rename to clients/daemon-cli/__tests__/elicitation-prompt.test.ts index 0977592450..1db33ba6e7 100644 --- a/clients/mcpi/__tests__/elicitation-prompt.test.ts +++ b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts @@ -12,10 +12,10 @@ vi.mock("node:readline/promises", () => ({ createInterface: () => ({ question, close, once }), })); -vi.mock("../src/session/form-prompt.js", async () => { +vi.mock("../src/connection/form-prompt.js", async () => { const actual = await vi.importActual< - typeof import("../src/session/form-prompt.js") - >("../src/session/form-prompt.js"); + typeof import("../src/connection/form-prompt.js") + >("../src/connection/form-prompt.js"); return { promptForm: (...args: unknown[]) => promptFormMock(...args), watchForClose: actual.watchForClose, @@ -91,7 +91,7 @@ describe("promptElicitation", () => { it("declines form-mode elicitations whose schema isn't the restricted primitive shape", async () => { const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame({ mode: "form", url: undefined }); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer).toEqual({ @@ -106,7 +106,7 @@ describe("promptElicitation", () => { it("declines form-mode elicitations non-interactively without prompting", async () => { const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame({ mode: "form", url: undefined, @@ -131,7 +131,7 @@ describe("promptElicitation", () => { it("cancels when the caller isn't interactive (e.g. --format json) without prompting", async () => { const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: false, @@ -149,7 +149,7 @@ describe("promptElicitation", () => { it("cancels non-interactively without a url line when the frame has none", async () => { const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame({ url: undefined }); const answer = await promptElicitation(frame, { interactive: false, @@ -162,7 +162,7 @@ describe("promptElicitation", () => { it("accepts when the interactive user confirms completion", async () => { question.mockResolvedValue(""); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer).toEqual({ @@ -179,7 +179,7 @@ describe("promptElicitation", () => { it("cancels when the interactive user types 'c'", async () => { question.mockResolvedValue("c"); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); @@ -188,7 +188,7 @@ describe("promptElicitation", () => { it("falls back to cancel if reading input throws", async () => { question.mockRejectedValue(new Error("stdin closed")); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); @@ -204,7 +204,7 @@ describe("promptElicitation", () => { if (event === "close") cb(); }); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); @@ -217,7 +217,7 @@ describe("promptElicitation", () => { content: { name: "octocat" }, }); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = formFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer).toEqual({ @@ -233,7 +233,7 @@ describe("promptElicitation", () => { it("declines an interactive form when promptForm reports decline", async () => { promptFormMock.mockResolvedValue({ action: "decline" }); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = formFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("decline"); @@ -242,7 +242,7 @@ describe("promptElicitation", () => { it("cancels an interactive form when promptForm reports cancel", async () => { promptFormMock.mockResolvedValue({ action: "cancel" }); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = formFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); @@ -251,7 +251,7 @@ describe("promptElicitation", () => { it("falls back to cancel if promptForm throws", async () => { promptFormMock.mockRejectedValue(new Error("stdin closed")); const { promptElicitation } = - await import("../src/session/elicitation-prompt.js"); + await import("../src/connection/elicitation-prompt.js"); const frame = formFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); diff --git a/clients/mcpi/__tests__/ema-commands.test.ts b/clients/daemon-cli/__tests__/ema-commands.test.ts similarity index 84% rename from clients/mcpi/__tests__/ema-commands.test.ts rename to clients/daemon-cli/__tests__/ema-commands.test.ts index 7c67b55c8b..fa084936b5 100644 --- a/clients/mcpi/__tests__/ema-commands.test.ts +++ b/clients/daemon-cli/__tests__/ema-commands.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PLAIN } from "@inspector/cli/style.js"; -import { formatEmaStatusHuman } from "../src/session/format-human.js"; +import { formatEmaStatusHuman } from "../src/connection/format-human.js"; const getEmaStatus = vi.fn(); const emaLogin = vi.fn(); const emaLogout = vi.fn(); -vi.mock("../src/session/ema.js", () => ({ +vi.mock("../src/connection/ema.js", () => ({ getEmaStatus: (...args: unknown[]) => getEmaStatus(...args), emaLogin: (...args: unknown[]) => emaLogin(...args), emaLogout: (...args: unknown[]) => emaLogout(...args), @@ -45,8 +45,8 @@ describe("auth/ema-* commands", () => { clientId: "idp-client", loginState: "logged_in", }); - const { runMcp } = await import("../src/session/mcp.js"); - await runMcp(["node", "mcpi", "auth/ema-status", "--format", "json"]); + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp(["node", "mcpdo", "auth/ema-status", "--format", "json"]); const parsed = JSON.parse(stdout.trim()); expect(parsed.issuer).toBe("https://idp.example.com"); expect(parsed.loginState).toBe("logged_in"); @@ -61,8 +61,8 @@ describe("auth/ema-* commands", () => { clientId: "idp-client", loginState: "none", }); - const { runMcp } = await import("../src/session/mcp.js"); - await runMcp(["node", "mcpi", "auth/ema-status"]); + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp(["node", "mcpdo", "auth/ema-status"]); expect(stdout).toContain("EMA (enterprise-managed auth):"); expect(stdout).toContain("https://idp.example.com"); expect(stdout).toContain("IdP session: none"); @@ -74,29 +74,29 @@ describe("auth/ema-* commands", () => { loginState: "logged_in", alreadyLoggedIn: false, }); - const { runMcp } = await import("../src/session/mcp.js"); - await runMcp(["node", "mcpi", "auth/ema-login", "--relogin"]); + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp(["node", "mcpdo", "auth/ema-login", "--relogin"]); expect(emaLogin).toHaveBeenCalledWith({ relogin: true }); expect(stdout).toContain("Signed in"); expect(stdout).toContain("https://idp.example.com"); }); - it("auth/ema-login reports an already-active session", async () => { + it("auth/ema-login reports an already-active connection", async () => { emaLogin.mockResolvedValue({ issuer: "https://idp.example.com", loginState: "logged_in", alreadyLoggedIn: true, }); - const { runMcp } = await import("../src/session/mcp.js"); - await runMcp(["node", "mcpi", "auth/ema-login"]); + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp(["node", "mcpdo", "auth/ema-login"]); expect(emaLogin).toHaveBeenCalledWith({ relogin: false }); expect(stdout).toContain("Already signed in"); }); it("auth/ema-logout prints the signed-out issuer", async () => { emaLogout.mockResolvedValue({ issuer: "https://idp.example.com" }); - const { runMcp } = await import("../src/session/mcp.js"); - await runMcp(["node", "mcpi", "auth/ema-logout"]); + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp(["node", "mcpdo", "auth/ema-logout"]); expect(stdout).toContain("Signed out"); expect(stdout).toContain("https://idp.example.com"); }); diff --git a/clients/mcpi/__tests__/ema.test.ts b/clients/daemon-cli/__tests__/ema.test.ts similarity index 87% rename from clients/mcpi/__tests__/ema.test.ts rename to clients/daemon-cli/__tests__/ema.test.ts index 149d23d44b..377c77e386 100644 --- a/clients/mcpi/__tests__/ema.test.ts +++ b/clients/daemon-cli/__tests__/ema.test.ts @@ -40,12 +40,12 @@ function fakeIdToken(): string { })}.sig`; } -describe("mcpi ema helpers", () => { +describe("mcpdo ema helpers", () => { let dir: string; let savedEnv: Record; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-ema-")); + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-ema-")); savedEnv = { MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, MCP_INSPECTOR_OAUTH_STATE_PATH: @@ -97,7 +97,7 @@ describe("mcpi ema helpers", () => { } it("getEmaStatus reports unconfigured when client.json has no EMA block", async () => { - const { getEmaStatus } = await import("../src/session/ema.js"); + const { getEmaStatus } = await import("../src/connection/ema.js"); const status = await getEmaStatus(); expect(status.configured).toBe(false); expect(status.enabled).toBe(false); @@ -107,7 +107,7 @@ describe("mcpi ema helpers", () => { it("getEmaStatus reports configured+enabled with no IdP session as 'none'", async () => { writeClientConfig(emaClientConfig()); - const { getEmaStatus } = await import("../src/session/ema.js"); + const { getEmaStatus } = await import("../src/connection/ema.js"); const status = await getEmaStatus(); expect(status.configured).toBe(true); expect(status.enabled).toBe(true); @@ -116,10 +116,10 @@ describe("mcpi ema helpers", () => { expect(status.loginState).toBe("none"); }); - it("getEmaStatus reports a disabled config (still shows issuer + session state)", async () => { + it("getEmaStatus reports a disabled config (still shows issuer + connection state)", async () => { writeClientConfig(emaClientConfig(false)); await seedIdpSession(); - const { getEmaStatus } = await import("../src/session/ema.js"); + const { getEmaStatus } = await import("../src/connection/ema.js"); const status = await getEmaStatus(); expect(status.configured).toBe(true); expect(status.enabled).toBe(false); @@ -127,7 +127,7 @@ describe("mcpi ema helpers", () => { }); it("emaLogin fails with actionable guidance when EMA is not configured", async () => { - const { emaLogin } = await import("../src/session/ema.js"); + const { emaLogin } = await import("../src/connection/ema.js"); await expect(emaLogin()).rejects.toThrow( /not configured.*client settings/is, ); @@ -138,19 +138,20 @@ describe("mcpi ema helpers", () => { it("emaLogin fails with actionable guidance when EMA is disabled", async () => { writeClientConfig(emaClientConfig(false)); - const { emaLogin } = await import("../src/session/ema.js"); + const { emaLogin } = await import("../src/connection/ema.js"); await expect(emaLogin()).rejects.toThrow(/disabled/i); }); it("emaLogout fails when EMA is not configured", async () => { - const { emaLogout } = await import("../src/session/ema.js"); + const { emaLogout } = await import("../src/connection/ema.js"); await expect(emaLogout()).rejects.toThrow(CliExitCodeError); }); it("emaLogout works even when EMA is disabled, and clears the IdP session", async () => { writeClientConfig(emaClientConfig(false)); await seedIdpSession(); - const { emaLogout, getEmaStatus } = await import("../src/session/ema.js"); + const { emaLogout, getEmaStatus } = + await import("../src/connection/ema.js"); const result = await emaLogout(); expect(result.issuer).toBe(ISSUER); expect((await getEmaStatus()).loginState).toBe("none"); @@ -159,7 +160,7 @@ describe("mcpi ema helpers", () => { it("emaLogin short-circuits when already signed in", async () => { writeClientConfig(emaClientConfig()); await seedIdpSession(); - const { emaLogin } = await import("../src/session/ema.js"); + const { emaLogin } = await import("../src/connection/ema.js"); const result = await emaLogin(); expect(result).toEqual({ issuer: ISSUER, @@ -169,7 +170,7 @@ describe("mcpi ema helpers", () => { expect(runRunnerInteractiveOAuth).not.toHaveBeenCalled(); }); - it("emaLogin runs the IdP flow via the runner adapter and reports the new session", async () => { + it("emaLogin runs the IdP flow via the runner adapter and reports the new connection", async () => { writeClientConfig(emaClientConfig()); let stderr = ""; const originalWrite = process.stderr.write; @@ -208,7 +209,7 @@ describe("mcpi ema helpers", () => { ); try { - const { emaLogin } = await import("../src/session/ema.js"); + const { emaLogin } = await import("../src/connection/ema.js"); const result = await emaLogin(); expect(result).toEqual({ issuer: ISSUER, @@ -234,7 +235,7 @@ describe("mcpi ema helpers", () => { ); }); - it("emaLogin --relogin clears the existing session and re-runs the flow", async () => { + it("emaLogin --relogin clears the existing connection and re-runs the flow", async () => { writeClientConfig(emaClientConfig()); await seedIdpSession(); startIdpOidcAuthorization.mockResolvedValue({ @@ -252,7 +253,7 @@ describe("mcpi ema helpers", () => { }; redirectUrlProvider: { redirectUrl: string }; }) => { - // The pre-existing session must already be gone before leg 1 runs. + // The pre-existing connection must already be gone before leg 1 runs. const storage = new NodeOAuthStorage(); expect(await storage.getIdpSession(ISSUER)).toBeUndefined(); await options.client.authenticate(); @@ -261,18 +262,18 @@ describe("mcpi ema helpers", () => { }, ); - const { emaLogin } = await import("../src/session/ema.js"); + const { emaLogin } = await import("../src/connection/ema.js"); const result = await emaLogin({ relogin: true }); expect(result.alreadyLoggedIn).toBe(false); expect(result.loginState).toBe("logged_in"); expect(runRunnerInteractiveOAuth).toHaveBeenCalledOnce(); }); - it("mcpiEmaGuidance names both configuration routes", async () => { - const { mcpiEmaGuidance } = await import("../src/session/ema.js"); - expect(mcpiEmaGuidance("not_configured")).toMatch( + it("mcpdoEmaGuidance names both configuration routes", async () => { + const { mcpdoEmaGuidance } = await import("../src/connection/ema.js"); + expect(mcpdoEmaGuidance("not_configured")).toMatch( /Client Settings.*enterpriseManagedAuth/is, ); - expect(mcpiEmaGuidance("disabled")).toContain("enabled"); + expect(mcpdoEmaGuidance("disabled")).toContain("enabled"); }); }); diff --git a/clients/mcpi/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts similarity index 99% rename from clients/mcpi/__tests__/form-prompt.test.ts rename to clients/daemon-cli/__tests__/form-prompt.test.ts index 9b77a75f4d..5816a53949 100644 --- a/clients/mcpi/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createStyle } from "@inspector/cli/style.js"; -import { promptForm } from "../src/session/form-prompt.js"; -import type { FormField } from "../src/session/form-schema.js"; +import { promptForm } from "../src/connection/form-prompt.js"; +import type { FormField } from "../src/connection/form-schema.js"; /** * Covers `promptForm`'s field-by-field prompting (one branch per diff --git a/clients/mcpi/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts similarity index 99% rename from clients/mcpi/__tests__/form-schema.test.ts rename to clients/daemon-cli/__tests__/form-schema.test.ts index facee5b402..d217dfd4c2 100644 --- a/clients/mcpi/__tests__/form-schema.test.ts +++ b/clients/daemon-cli/__tests__/form-schema.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseFormSchema } from "../src/session/form-schema.js"; +import { parseFormSchema } from "../src/connection/form-schema.js"; describe("parseFormSchema", () => { it("returns null for a non-object schema", () => { diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts similarity index 93% rename from clients/mcpi/__tests__/format-session.test.ts rename to clients/daemon-cli/__tests__/format-connection.test.ts index 36100be9fb..30d1287974 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -15,15 +15,15 @@ import { formatAuthListHuman, formatServersListHuman, formatServerShowHuman, - formatSessionsListHuman, - formatSessionInfoHuman, + formatConnectionsListHuman, + formatConnectionInfoHuman, formatAppInfoListHuman, formatAppInfoHuman, formatSkillVerifyListHuman, formatStreamEventHuman, formatRpcResultHuman, -} from "../src/session/format-human.js"; -import { writeSessionOutput } from "../src/session/format-session.js"; +} from "../src/connection/format-human.js"; +import { writeConnectionOutput } from "../src/connection/format-connection.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { createStyle } from "@inspector/cli/style.js"; @@ -317,7 +317,7 @@ describe("format-human", () => { name: "s", type: "stdio", detail: "x", - session: "s", + connection: "s", isMru: true, }, ]), @@ -331,34 +331,38 @@ describe("format-human", () => { }), ).toMatch(/Server[\s\S]*`s`[\s\S]*node x/); - expect(formatSessionsListHuman([])).toContain("connect first"); + expect(formatConnectionsListHuman([])).toContain("connect first"); expect( - formatSessionsListHuman([ + formatConnectionsListHuman([ { name: "a", isMru: true, serverIdentity: "id" }, ]), ).toContain("(MRU)"); - // protocolEra is on every SessionInfo now (#2298 follow-up), not just - // sessions/show — sessions/list renders it inline; its absence (an older + // protocolEra is on every ConnectionInfo now (#2298 follow-up), not just + // connections/show — connections/list renders it inline; its absence (an older // daemon reply, hypothetically) must not print a bare "[undefined]". expect( - formatSessionsListHuman([ + formatConnectionsListHuman([ { name: "a", isMru: true, serverIdentity: "id", protocolEra: "modern" }, ]), ).toContain("— id [modern]"); expect( - formatSessionsListHuman([ + formatConnectionsListHuman([ { name: "a", isMru: false, serverIdentity: "id" }, ]), ).not.toContain("["); expect( - formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), - ).toContain("Session `@a`"); - // sessions/show enrichment: era without a protocolVersion, serverInfo + formatConnectionInfoHuman({ + name: "a", + isMru: true, + serverIdentity: "id", + }), + ).toContain("Connection `@a`"); + // connections/show enrichment: era without a protocolVersion, serverInfo // without a version, empty capabilities, an empty/non-array // supportedVersions, and blank instructions each take the "nothing to // append" branch rather than the populated one exercised elsewhere. expect( - formatSessionInfoHuman({ + formatConnectionInfoHuman({ name: "a", protocolEra: "legacy", serverInfo: { name: "demo" }, @@ -368,7 +372,7 @@ describe("format-human", () => { }), ).toMatch(/Era: legacy\nServer info: demo\nCapabilities: \(none\)/); expect( - formatSessionInfoHuman({ + formatConnectionInfoHuman({ name: "a", protocolEra: undefined, protocolVersion: "2025-11-25", @@ -383,7 +387,7 @@ describe("format-human", () => { // Auth snapshot line: OAuth with full detail, EMA with IdP session state, // and a bare not-authorized snapshot (no scope/clientId branches). expect( - formatSessionInfoHuman({ + formatConnectionInfoHuman({ name: "a", auth: { method: "oauth", @@ -396,13 +400,13 @@ describe("format-human", () => { "Auth: OAuth (authorized; scope: mcp:tools; client: client-123)", ); expect( - formatSessionInfoHuman({ + formatConnectionInfoHuman({ name: "a", auth: { method: "ema", authorized: true, idpSession: "logged_in" }, }), ).toContain("Auth: EMA (authorized; IdP session: logged_in)"); expect( - formatSessionInfoHuman({ + formatConnectionInfoHuman({ name: "a", auth: { method: "oauth", authorized: false }, }), @@ -548,7 +552,7 @@ describe("format-human", () => { }); }); -describe("writeSessionOutput", () => { +describe("writeConnectionOutput", () => { let stdout: string; let stderr: string; let original: typeof process.stdout.write; @@ -583,7 +587,7 @@ describe("writeSessionOutput", () => { }); it("pretty-prints json without a result envelope", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "json" }, { kind: "rpc", @@ -595,7 +599,7 @@ describe("writeSessionOutput", () => { }); it("sanitizes server-supplied terminal escapes in text mode", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "text" }, { kind: "rpc", @@ -611,7 +615,7 @@ describe("writeSessionOutput", () => { }); it("leaves json output verbatim (JSON escaping already protects it)", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "json" }, { kind: "rpc", @@ -626,7 +630,7 @@ describe("writeSessionOutput", () => { }); it("sanitizes the ndjson stderr summary line", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "text" }, { kind: "ndjson", @@ -640,7 +644,7 @@ describe("writeSessionOutput", () => { }); it("ignores auto-collected appInfo on tools/call json", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "json" }, { kind: "rpc", @@ -656,7 +660,7 @@ describe("writeSessionOutput", () => { it("throws NO_APP after printing app-info text", async () => { await expect( - writeSessionOutput( + writeConnectionOutput( { format: "text" }, { kind: "rpc", @@ -669,7 +673,7 @@ describe("writeSessionOutput", () => { }); it("allows hasApp true app-info probes", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "text" }, { kind: "rpc", @@ -682,7 +686,7 @@ describe("writeSessionOutput", () => { it("throws TOOL_ERROR when isError", async () => { await expect( - writeSessionOutput( + writeConnectionOutput( { format: "json" }, { kind: "rpc", @@ -693,7 +697,7 @@ describe("writeSessionOutput", () => { ), ).rejects.toBeInstanceOf(CliExitCodeError); await expect( - writeSessionOutput( + writeConnectionOutput( { format: "json" }, { kind: "rpc", @@ -705,7 +709,7 @@ describe("writeSessionOutput", () => { }); it("falls back to pretty JSON for unknown rpc methods in text mode", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "text" }, { kind: "rpc", @@ -717,7 +721,7 @@ describe("writeSessionOutput", () => { }); it("renders skill-verify NDJSON with its own formatter, not app-info's", async () => { - await writeSessionOutput( + await writeConnectionOutput( { format: "text" }, { kind: "ndjson", @@ -737,7 +741,7 @@ describe("writeSessionOutput", () => { it("throws with the verify exit code after printing the report and summary", async () => { await expect( - writeSessionOutput( + writeConnectionOutput( { format: "json" }, { kind: "ndjson", @@ -783,21 +787,21 @@ describe("writeSessionOutput", () => { }, }, { - kind: "sessions/list" as const, - sessions: [{ name: "a", serverIdentity: "id" }], + kind: "connections/list" as const, + connections: [{ name: "a", serverIdentity: "id" }], }, { - kind: "session" as const, - session: { name: "a", serverIdentity: "id" }, + kind: "connection" as const, + connection: { name: "a", serverIdentity: "id" }, }, { kind: "disconnect" as const, name: "a" }, { kind: "daemon/status" as const, - status: { pid: 1, socketPath: "/tmp/s", sessions: [] }, + status: { pid: 1, socketPath: "/tmp/s", connections: [] }, }, { kind: "daemon/status" as const, - status: { pid: 2, sessions: "bad" }, + status: { pid: 2, connections: "bad" }, }, { kind: "daemon/stop" as const, @@ -842,16 +846,16 @@ describe("writeSessionOutput", () => { for (const payload of kinds) { stdout = ""; - await writeSessionOutput({ format: "text" }, payload); + await writeConnectionOutput({ format: "text" }, payload); expect(stdout.length).toBeGreaterThan(0); stdout = ""; - await writeSessionOutput({ format: "json" }, payload); + await writeConnectionOutput({ format: "json" }, payload); expect(() => JSON.parse(stdout)).not.toThrow(); } }); it("defaults undefined format to text", async () => { - await writeSessionOutput( + await writeConnectionOutput( {}, { kind: "disconnect", diff --git a/clients/mcpi/__tests__/helpers/mcp-runner.ts b/clients/daemon-cli/__tests__/helpers/mcp-runner.ts similarity index 89% rename from clients/mcpi/__tests__/helpers/mcp-runner.ts rename to clients/daemon-cli/__tests__/helpers/mcp-runner.ts index 341dd37cd7..3952aa6a1d 100644 --- a/clients/mcpi/__tests__/helpers/mcp-runner.ts +++ b/clients/daemon-cli/__tests__/helpers/mcp-runner.ts @@ -1,4 +1,4 @@ -import { runMcp as invokeMcp } from "../../src/session/mcp.js"; +import { runMcp as invokeMcp } from "../../src/connection/mcp.js"; import { formatErrorOutput } from "@inspector/cli/error-handler.js"; export interface McpResult { @@ -30,7 +30,7 @@ function captureWrite(append: (text: string) => void) { } /** - * In-process runner for `runMcp` (session CLI), mirroring {@link runCli}. + * In-process runner for `runMcp` (connection CLI), mirroring {@link runCli}. */ export async function runMcp( args: string[], @@ -57,12 +57,12 @@ export async function runMcp( stderr += text; }) as typeof process.stderr.write; - const argv = ["node", "mcpi", ...args]; + const argv = ["node", "mcpdo", ...args]; const timeoutMs = options.timeout ?? 15000; let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout( - () => reject(new Error(`mcpi command timed out after ${timeoutMs}ms`)), + () => reject(new Error(`mcpdo command timed out after ${timeoutMs}ms`)), timeoutMs, ); }); diff --git a/clients/daemon-cli/__tests__/hoist-connection.test.ts b/clients/daemon-cli/__tests__/hoist-connection.test.ts new file mode 100644 index 0000000000..3d1cd7d9a7 --- /dev/null +++ b/clients/daemon-cli/__tests__/hoist-connection.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { hoistAtConnection } from "../src/connection/dispatch.js"; +import { expandConnAlias } from "../src/connection/mcp.js"; + +describe("hoistAtConnection", () => { + it("lifts a leading @name into connectionFromAt", () => { + const { argv, connectionFromAt } = hoistAtConnection([ + "node", + "mcpdo", + "@alpha", + "tools/list", + "--format", + "json", + ]); + expect(connectionFromAt).toBe("alpha"); + expect(argv).toEqual(["node", "mcpdo", "tools/list", "--format", "json"]); + }); + + it("leaves argv unchanged when there is no @name", () => { + const input = ["node", "mcpdo", "tools/list"]; + expect(hoistAtConnection(input)).toEqual({ argv: input }); + }); +}); + +describe("expandConnAlias", () => { + it("expands --conn and --conn= to --connection forms", () => { + expect( + expandConnAlias(["node", "mcpdo", "--conn", "alpha", "tools/list"]), + ).toEqual(["node", "mcpdo", "--connection", "alpha", "tools/list"]); + expect(expandConnAlias(["node", "mcpdo", "--conn=alpha"])).toEqual([ + "node", + "mcpdo", + "--connection=alpha", + ]); + }); + + it("leaves --connection, --config, and other args unchanged", () => { + const input = [ + "node", + "mcpdo", + "--connection", + "alpha", + "--config", + "x.json", + "--connect-timeout", + "5", + ]; + expect(expandConnAlias(input)).toEqual(input); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts similarity index 90% rename from clients/mcpi/__tests__/mcp-auth-coverage.test.ts rename to clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts index ac6448e638..7bffe7b51b 100644 --- a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts @@ -15,7 +15,7 @@ vi.mock("../src/daemon/index.js", () => ({ streamDaemon: vi.fn(), })); -vi.mock("../src/session/authorize.js", () => ({ +vi.mock("../src/connection/authorize.js", () => ({ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), })); @@ -69,10 +69,10 @@ describe("mcp.ts auth / daemon error paths", () => { serverIdentity: "stdio", }); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await runMcp([ "node", - "mcpi", + "mcpdo", "connect", "test-stdio", "--config", @@ -91,7 +91,7 @@ describe("mcp.ts auth / daemon error paths", () => { it("retries connect after auth_required via authorizeInFrontend", async () => { configPath = createSampleTestConfig(); - const session = { + const connection = { name: "test-stdio", isMru: true, serverIdentity: "stdio", @@ -102,12 +102,12 @@ describe("mcp.ts auth / daemon error paths", () => { code: "auth_required", }), ) - .mockResolvedValueOnce(session); + .mockResolvedValueOnce(connection); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await runMcp([ "node", - "mcpi", + "mcpdo", "connect", "test-stdio", "--config", @@ -123,7 +123,7 @@ describe("mcp.ts auth / daemon error paths", () => { it("re-ensures the daemon after authorizeInFrontend, in case interactive OAuth outlasted its idle timeout", async () => { configPath = createSampleTestConfig(); - const session = { + const connection = { name: "test-stdio", isMru: true, serverIdentity: "stdio", @@ -134,7 +134,7 @@ describe("mcp.ts auth / daemon error paths", () => { code: "auth_required", }), ) - .mockResolvedValueOnce(session); + .mockResolvedValueOnce(connection); // Simulate the pre-auth daemon having idled out while OAuth ran: the // retry's ensureDaemon() call returns a different (freshly respawned) // socket than the one used for the first attempt. @@ -142,10 +142,10 @@ describe("mcp.ts auth / daemon error paths", () => { .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-stale.sock" }) .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-fresh.sock" }); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await runMcp([ "node", - "mcpi", + "mcpdo", "connect", "test-stdio", "--config", @@ -166,11 +166,11 @@ describe("mcp.ts auth / daemon error paths", () => { it("rejects --relogin with --stored-auth-only", async () => { configPath = createSampleTestConfig(); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await expect( runMcp([ "node", - "mcpi", + "mcpdo", "--stored-auth-only", "connect", "test-stdio", @@ -213,12 +213,12 @@ describe("mcp.ts auth / daemon error paths", () => { }); try { - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await runMcp([ "node", - "mcpi", + "mcpdo", "connect", - "--session", + "--connection", "relogin-http", "--server-url", "http://example.com/mcp", @@ -249,11 +249,11 @@ describe("mcp.ts auth / daemon error paths", () => { }), ); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await expect( runMcp([ "node", - "mcpi", + "mcpdo", "connect", "test-stdio", "--config", @@ -274,9 +274,9 @@ describe("mcp.ts auth / daemon error paths", () => { new CliExitCodeError(EXIT_CODES.USAGE, "boom", { code: "usage" }), ); - const { runMcp } = await import("../src/session/mcp.js"); + const { runMcp } = await import("../src/connection/mcp.js"); await expect( - runMcp(["node", "mcpi", "daemon", "stop", "--format", "json"]), + runMcp(["node", "mcpdo", "daemon", "stop", "--format", "json"]), ).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE, envelope: { code: "usage" }, diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/daemon-cli/__tests__/mcp-connection.test.ts similarity index 75% rename from clients/mcpi/__tests__/mcp-session.test.ts rename to clients/daemon-cli/__tests__/mcp-connection.test.ts index e20e192234..7453b05359 100644 --- a/clients/mcpi/__tests__/mcp-session.test.ts +++ b/clients/daemon-cli/__tests__/mcp-connection.test.ts @@ -12,7 +12,7 @@ import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; import { callDaemon } from "../src/daemon/client.js"; -describe("mcp session CLI", () => { +describe("mcp connection CLI", () => { let configPath: string | undefined; let storageDir: string | undefined; @@ -45,11 +45,11 @@ describe("mcp session CLI", () => { }); function env(): Record { - storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-session-")); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-connection-")); return { MCP_STORAGE_DIR: storageDir, MCP_INSPECTOR_DAEMON_DIR: storageDir, - MCP_ALLOW_DEFAULT_SESSION: "1", + MCP_ALLOW_DEFAULT_CONNECTION: "1", }; } @@ -70,7 +70,7 @@ describe("mcp session CLI", () => { expect(body.servers.some((s) => s.name === "test-stdio")).toBe(true); }); - it("connects, lists sessions, disconnects via auto-spawned daemon", async () => { + it("connects, lists connections, disconnects via auto-spawned daemon", async () => { configPath = createSampleTestConfig(); const e = env(); @@ -79,22 +79,22 @@ describe("mcp session CLI", () => { { env: e, timeout: 20000 }, ); expectCliSuccess(connected); - const session = JSON.parse(connected.stdout) as { + const connection = JSON.parse(connected.stdout) as { name: string; isMru: boolean; }; - expect(session.name).toBe("test-stdio"); - expect(session.isMru).toBe(true); + expect(connection.name).toBe("test-stdio"); + expect(connection.isMru).toBe(true); - const listed = await runMcp(["sessions/list", "--format", "json"], { + const listed = await runMcp(["connections/list", "--format", "json"], { env: e, }); expectCliSuccess(listed); - const sessions = JSON.parse(listed.stdout) as { - sessions: { name: string; isMru: boolean }[]; + const connections = JSON.parse(listed.stdout) as { + connections: { name: string; isMru: boolean }[]; }; - expect(sessions.sessions).toHaveLength(1); - expect(sessions.sessions[0]?.name).toBe("test-stdio"); + expect(connections.connections).toHaveLength(1); + expect(connections.connections[0]?.name).toBe("test-stdio"); const servers = await runMcp( ["servers/list", "--config", configPath, "--format", "json"], @@ -104,19 +104,19 @@ describe("mcp session CLI", () => { const serverBody = JSON.parse(servers.stdout) as { servers: { name: string; - session?: string; + connection?: string; isMru?: boolean; }[]; }; const stdio = serverBody.servers.find((s) => s.name === "test-stdio"); - expect(stdio?.session).toBe("test-stdio"); + expect(stdio?.connection).toBe("test-stdio"); expect(stdio?.isMru).toBe(true); expect( - serverBody.servers.find((s) => s.name === "test-http")?.session, + serverBody.servers.find((s) => s.name === "test-http")?.connection, ).toBeUndefined(); const disc = await runMcp( - ["disconnect", "--session", "test-stdio", "--format", "json"], + ["disconnect", "--connection", "test-stdio", "--format", "json"], { env: e }, ); expectCliSuccess(disc); @@ -127,7 +127,7 @@ describe("mcp session CLI", () => { expectCliSuccess(stopped); }); - it("one-shot servers/list still works alongside session mode", async () => { + it("one-shot servers/list still works alongside connection mode", async () => { configPath = createSampleTestConfig(); const result = await runCli([ "--config", @@ -139,7 +139,7 @@ describe("mcp session CLI", () => { expect(result.stdout).toContain("test-stdio"); }); - it("runs tools/list, tools/call, and sessions/show over a live session", async () => { + it("runs tools/list, tools/call, and connections/show over a live connection", async () => { configPath = createSampleTestConfig(); const e = env(); @@ -168,13 +168,19 @@ describe("mcp session CLI", () => { expect(toolsText.stdout).toContain("`"); const called = await runMcp( - ["tools/call", "echo", "message:=session", "--format", "json"], + ["tools/call", "echo", "message:=connection", "--format", "json"], { env: e, timeout: 20000 }, ); expectCliSuccess(called); const calledJson = await runMcp( - ["tools/call", "echo", '{"message":"session-json"}', "--format", "json"], + [ + "tools/call", + "echo", + '{"message":"connection-json"}', + "--format", + "json", + ], { env: e, timeout: 20000 }, ); expectCliSuccess(calledJson); @@ -186,7 +192,7 @@ describe("mcp session CLI", () => { expectCliSuccess(resources); const shown = await runMcp( - ["@test-stdio", "sessions/show", "--format", "json"], + ["@test-stdio", "connections/show", "--format", "json"], { env: e, timeout: 20000 }, ); expectCliSuccess(shown); @@ -199,16 +205,16 @@ describe("mcp session CLI", () => { expect(shownBody.protocolVersion).toBeTruthy(); expect(shownBody.protocolEra).toBeTruthy(); - // `sessions/show ` (positional, no `@name`/--session) exercises - // the opts.session-absent fallback to the command's own argument. + // `connections/show ` (positional, no `@name`/--connection) exercises + // the opts.connection-absent fallback to the command's own argument. const shownByArg = await runMcp( - ["sessions/show", "test-stdio", "--format", "json"], + ["connections/show", "test-stdio", "--format", "json"], { env: e, timeout: 20000 }, ); expectCliSuccess(shownByArg); await runMcp( - ["disconnect", "--session", "test-stdio", "--format", "json"], + ["disconnect", "--connection", "test-stdio", "--format", "json"], { env: e, }, diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-coverage.test.ts similarity index 94% rename from clients/mcpi/__tests__/mcp-coverage.test.ts rename to clients/daemon-cli/__tests__/mcp-coverage.test.ts index 32c9f87cc3..c6240cb832 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-coverage.test.ts @@ -51,7 +51,7 @@ describe("mcp.ts coverage", () => { return { MCP_STORAGE_DIR: storageDir, MCP_INSPECTOR_DAEMON_DIR: storageDir, - MCP_ALLOW_DEFAULT_SESSION: "1", + MCP_ALLOW_DEFAULT_CONNECTION: "1", }; } @@ -69,7 +69,7 @@ describe("mcp.ts coverage", () => { [ "tools/list", "--metadata", - "client=session-cov", + "client=connection-cov", "--metadata", "count=1", // Object value must JSON.stringify (not String → "[object Object]"). @@ -307,7 +307,7 @@ describe("mcp.ts coverage", () => { expect([0, 1]).toContain(skillsGetFlagUri.exitCode); await runMcp( - ["disconnect", "--session", "test-stdio", "--format", "json"], + ["disconnect", "--connection", "test-stdio", "--format", "json"], { env: e, }, @@ -323,7 +323,7 @@ describe("mcp.ts coverage", () => { const adHoc = await runMcp( [ "connect", - "--session", + "--connection", "opts", "--transport", "stdio", @@ -363,7 +363,7 @@ describe("mcp.ts coverage", () => { expectCliFailure(badElicit); expect(badElicit.stderr).toMatch(/Invalid --elicit/); - await runMcp(["disconnect", "--session", "opts", "--format", "json"], { + await runMcp(["disconnect", "--connection", "opts", "--format", "json"], { env: e, }); @@ -371,7 +371,7 @@ describe("mcp.ts coverage", () => { const urlOnly = await runMcp( [ "connect", - "--session", + "--connection", "urlonly", "--transport", "http", @@ -408,7 +408,7 @@ describe("mcp.ts coverage", () => { await runMcp(["daemon", "stop", "--format", "json"], { env: e }); }); - it("bare mcpi / --help print usage without an ErrorEnvelope", async () => { + it("bare mcpdo / --help print usage without an ErrorEnvelope", async () => { // Bare invocation: Commander writes help to stderr (help-after-error). const bare = await runMcp([]); expectCliSuccess(bare); @@ -430,7 +430,7 @@ describe("mcp.ts coverage", () => { const originalArgv = process.argv; process.argv = [ "node", - "mcpi", + "mcpdo", "servers/list", "--config", configPath, @@ -438,20 +438,20 @@ describe("mcp.ts coverage", () => { "json", ]; try { - const { runMcp: invoke } = await import("../src/session/mcp.js"); + const { runMcp: invoke } = await import("../src/connection/mcp.js"); await invoke(); } finally { process.argv = originalArgv; } }); - it("sessions/list and daemon status do not auto-spawn the daemon", async () => { + it("connections/list and daemon status do not auto-spawn the daemon", async () => { const e = env(); - const listed = await runMcp(["sessions/list", "--format", "json"], { + const listed = await runMcp(["connections/list", "--format", "json"], { env: e, }); expectCliSuccess(listed); - expect(JSON.parse(listed.stdout)).toEqual({ sessions: [] }); + expect(JSON.parse(listed.stdout)).toEqual({ connections: [] }); const status = await runMcp(["daemon", "status", "--format", "json"], { env: e, diff --git a/clients/mcpi/__tests__/parse-tool-args.test.ts b/clients/daemon-cli/__tests__/parse-tool-args.test.ts similarity index 98% rename from clients/mcpi/__tests__/parse-tool-args.test.ts rename to clients/daemon-cli/__tests__/parse-tool-args.test.ts index 971fa620d0..b1bc380ac5 100644 --- a/clients/mcpi/__tests__/parse-tool-args.test.ts +++ b/clients/daemon-cli/__tests__/parse-tool-args.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseToolCallPositionals, resolveToolCallArgs, -} from "../src/session/parse-tool-args.js"; +} from "../src/connection/parse-tool-args.js"; describe("parseToolCallPositionals", () => { it("parses key:=value with JSON typing", () => { diff --git a/clients/mcpi/__tests__/resolve-command.test.ts b/clients/daemon-cli/__tests__/resolve-command.test.ts similarity index 94% rename from clients/mcpi/__tests__/resolve-command.test.ts rename to clients/daemon-cli/__tests__/resolve-command.test.ts index 2466f18fde..1b9b707c66 100644 --- a/clients/mcpi/__tests__/resolve-command.test.ts +++ b/clients/daemon-cli/__tests__/resolve-command.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; -import { resolveCommandPath } from "../src/session/resolve-command.js"; +import { resolveCommandPath } from "../src/connection/resolve-command.js"; -const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcpi-resolve-")); +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-resolve-")); afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); diff --git a/clients/mcpi/__tests__/sanitize.test.ts b/clients/daemon-cli/__tests__/sanitize.test.ts similarity index 94% rename from clients/mcpi/__tests__/sanitize.test.ts rename to clients/daemon-cli/__tests__/sanitize.test.ts index d01d50c383..6b9abfe12d 100644 --- a/clients/mcpi/__tests__/sanitize.test.ts +++ b/clients/daemon-cli/__tests__/sanitize.test.ts @@ -1,11 +1,11 @@ /** * Terminal-escape sanitization (security). Server-controlled strings must - * never reach the terminal as raw control bytes — see src/session/sanitize.ts + * never reach the terminal as raw control bytes — see src/connection/sanitize.ts * for the threat catalogue (OSC 52 clipboard writes, title spoofing, CSI * rewriting, OSC 8 hyperlink breakout). */ import { describe, expect, it } from "vitest"; -import { sanitizeDeep, sanitizeText } from "../src/session/sanitize.js"; +import { sanitizeDeep, sanitizeText } from "../src/connection/sanitize.js"; describe("sanitizeText", () => { it("neutralizes an OSC 52 clipboard-write sequence", () => { diff --git a/clients/mcpi/eslint.config.js b/clients/daemon-cli/eslint.config.js similarity index 100% rename from clients/mcpi/eslint.config.js rename to clients/daemon-cli/eslint.config.js diff --git a/clients/mcpi/package-lock.json b/clients/daemon-cli/package-lock.json similarity index 99% rename from clients/mcpi/package-lock.json rename to clients/daemon-cli/package-lock.json index ba36e731ab..51ea8674dc 100644 --- a/clients/mcpi/package-lock.json +++ b/clients/daemon-cli/package-lock.json @@ -1,13 +1,13 @@ { - "name": "@modelcontextprotocol/mcpi", + "name": "@modelcontextprotocol/daemon-cli", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@modelcontextprotocol/mcpi", + "name": "@modelcontextprotocol/daemon-cli", "license": "MIT", "bin": { - "mcpi": "build/mcp-bin.js" + "mcpdo": "build/mcp-bin.js" }, "devDependencies": { "@types/express": "^5.0.6", diff --git a/clients/mcpi/package.json b/clients/daemon-cli/package.json similarity index 86% rename from clients/mcpi/package.json rename to clients/daemon-cli/package.json index c680c90b4d..1d071c63cc 100644 --- a/clients/mcpi/package.json +++ b/clients/daemon-cli/package.json @@ -1,12 +1,12 @@ { - "name": "@modelcontextprotocol/mcpi", + "name": "@modelcontextprotocol/daemon-cli", "private": true, - "description": "Session-oriented MCP Inspector CLI (mcpi) — connect once, run many commands", + "description": "Connection-oriented MCP Inspector CLI (mcpdo) — connect once, run many commands", "license": "MIT", "type": "module", "main": "build/mcp-bin.js", "bin": { - "mcpi": "./build/mcp-bin.js" + "mcpdo": "./build/mcp-bin.js" }, "files": [ "build", diff --git a/clients/mcpi/src/session/authorize.ts b/clients/daemon-cli/src/connection/authorize.ts similarity index 93% rename from clients/mcpi/src/session/authorize.ts rename to clients/daemon-cli/src/connection/authorize.ts index 82d3566ee7..7baacd1757 100644 --- a/clients/mcpi/src/session/authorize.ts +++ b/clients/daemon-cli/src/connection/authorize.ts @@ -23,7 +23,7 @@ import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; -import { mcpiEmaGuidance } from "./ema.js"; +import { mcpdoEmaGuidance } from "./ema.js"; /** * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens @@ -54,7 +54,7 @@ export async function authorizeInFrontend( const autoOpenControl = { armed: false }; environment.oauth = { storage: new NodeOAuthStorage(), - // mcpi always attempts interactive OAuth (see the isTTY override below) — + // mcpdo always attempts interactive OAuth (see the isTTY override below) — // whoever is running it (human or agent) may not have a real TTY on // stdin/stderr. Reword the printed line so an agent knows it must relay // the link to a human rather than treating "Please navigate to" as @@ -103,7 +103,7 @@ export async function authorizeInFrontend( serverSettings, { storedAuthOnly: options?.storedAuthOnly, - // mcpi runs as a front-end for whatever invoked it (human terminal or + // mcpdo runs as a front-end for whatever invoked it (human terminal or // agent subprocess) — always admit interactive OAuth rather than // refusing when stdin/stderr aren't a real TTY. The CI-hang concern // behind that gate (see clients/cli/README.md OAuth section) doesn't @@ -118,11 +118,11 @@ export async function authorizeInFrontend( } catch (err) { // An EMA server without active install-level IdP config: interactive // OAuth cannot fix this, so replace the core error (which points at the - // web Client Settings dialog only) with mcpi-appropriate guidance. + // web Client Settings dialog only) with mcp-conn-appropriate guidance. if (isEmaClientNotConfiguredError(err)) { throw new CliExitCodeError( EXIT_CODES.AUTH_REQUIRED, - mcpiEmaGuidance(err.reason), + mcpdoEmaGuidance(err.reason), { code: "auth_required" }, ); } diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts similarity index 81% rename from clients/mcpi/src/session/dispatch.ts rename to clients/daemon-cli/src/connection/dispatch.ts index c7b912050e..5fae29b1b8 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -5,7 +5,7 @@ import type { MethodArgs, } from "@inspector/cli/handlers/method-types.js"; import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; -import { writeSessionOutput } from "./format-session.js"; +import { writeConnectionOutput } from "./format-connection.js"; import { styleFromOpts } from "@inspector/cli/style.js"; import { promptElicitation } from "./elicitation-prompt.js"; @@ -19,20 +19,20 @@ const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); */ const NDJSON_VARIANTS = new Set(["skills/list", "skills/get"]); -export type SessionDispatchOpts = { +export type ConnectionDispatchOpts = { format?: OutputFormat; plain?: boolean; - session?: string; + connection?: string; requireExplicit: boolean; }; /** - * Run one session MCP method via daemon `rpc` or `stream`. + * Run one connection MCP method via daemon `rpc` or `stream`. */ -export async function dispatchSessionRpc( +export async function dispatchConnectionRpc( method: string, methodArgs: MethodArgs, - opts: SessionDispatchOpts, + opts: ConnectionDispatchOpts, ): Promise { const format: OutputFormat = opts.format ?? "text"; const style = styleFromOpts({ plain: opts.plain, format }); @@ -40,7 +40,7 @@ export async function dispatchSessionRpc( ...methodArgs, format, method, - name: stripAt(opts.session), + name: stripAt(opts.connection), requireExplicit: opts.requireExplicit, }; @@ -56,7 +56,7 @@ export async function dispatchSessionRpc( socketPath, signal: ac.signal, onData: (data) => { - void writeSessionOutput( + void writeConnectionOutput( { format, style }, { kind: "stream-event", @@ -100,7 +100,7 @@ export async function dispatchSessionRpc( process.off("SIGTERM", onSignal); } if (outcome.kind === "ndjson") { - await writeSessionOutput( + await writeConnectionOutput( { format, style }, { kind: "ndjson", @@ -112,7 +112,7 @@ export async function dispatchSessionRpc( ); return; } - await writeSessionOutput( + await writeConnectionOutput( { format, style }, { kind: "rpc", @@ -130,21 +130,21 @@ export function stripAt(name: string | undefined): string | undefined { } /** - * Non-interactive runs must pass an explicit session for MRU-targeting ops. - * Key off stdin (not stdout) so piping output (`mcpi tools/list | jq`) still + * Non-interactive runs must pass an explicit connection for MRU-targeting ops. + * Key off stdin (not stdout) so piping output (`mcpdo tools/list | jq`) still * uses MRU when a human is at the keyboard. */ -export function requireExplicitSession(): boolean { - if (process.env.MCP_ALLOW_DEFAULT_SESSION === "1") return false; +export function requireExplicitConnection(): boolean { + if (process.env.MCP_ALLOW_DEFAULT_CONNECTION === "1") return false; return process.stdin.isTTY !== true; } /** - * Hoist a leading `@name` from argv so `mcpi @alpha tools/list` works. + * Hoist a leading `@name` from argv so `mcpdo @alpha tools/list` works. */ -export function hoistAtSession(argv: string[]): { +export function hoistAtConnection(argv: string[]): { argv: string[]; - sessionFromAt?: string; + connectionFromAt?: string; } { const start = 2; const user = argv.slice(start); @@ -152,7 +152,7 @@ export function hoistAtSession(argv: string[]): { if (token && /^@[A-Za-z0-9_.-]+$/.test(token)) { return { argv: [...argv.slice(0, start), ...user.slice(1)], - sessionFromAt: token.slice(1), + connectionFromAt: token.slice(1), }; } return { argv }; diff --git a/clients/mcpi/src/session/elicitation-prompt.ts b/clients/daemon-cli/src/connection/elicitation-prompt.ts similarity index 97% rename from clients/mcpi/src/session/elicitation-prompt.ts rename to clients/daemon-cli/src/connection/elicitation-prompt.ts index 72b35d042c..7f08811fa2 100644 --- a/clients/mcpi/src/session/elicitation-prompt.ts +++ b/clients/daemon-cli/src/connection/elicitation-prompt.ts @@ -31,7 +31,7 @@ export type PromptElicitationOpts = { * non-TTY stdin/stderr as it does at a real terminal — a human at a * keyboard and an agent relaying/answering on their behalf both just * read a line of text and reply with one. A stdin that's already closed - * (e.g. `mcpi ... { export type EmaLogoutResult = { issuer: string }; /** - * Sign out of the enterprise IdP: clears the cached IdP OIDC session and all + * Sign out of the enterprise IdP: clears the cached IdP OIDC connection and all * EMA-minted resource-server tokens. Works even when EMA is disabled (state * cleanup should never be blocked by the enabled flag). */ @@ -152,7 +152,7 @@ export type EmaLoginResult = { * Sign in to the enterprise IdP (EMA leg 1 only — no server required): print * the IdP authorization URL, wait on the loopback callback, and exchange the * code for an IdP session. Subsequent connects to EMA servers mint resource - * tokens silently from this session. + * tokens silently from this connection. * * Non-TTY (agent-attended) callers get wording that directs the agent to * relay the link to the human user, mirroring `authorizeInFrontend`. SIGINT / @@ -224,7 +224,7 @@ export async function emaLogin(options?: { }, redirectUrlProvider, callbackListen: callbackUrlConfig, - // mcpi is a plain CLI (no Ink); own Ctrl-C during the IdP wait. + // mcpdo is a plain CLI (no Ink); own Ctrl-C during the IdP wait. handleSignals: true, }); resetNodeOAuthStorageCache(); diff --git a/clients/mcpi/src/session/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts similarity index 99% rename from clients/mcpi/src/session/form-prompt.ts rename to clients/daemon-cli/src/connection/form-prompt.ts index 87931a2c1e..502151035b 100644 --- a/clients/mcpi/src/session/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -19,7 +19,7 @@ export type FormOutcome = * A promise that rejects the first time `rl`'s underlying input stream * closes (EOF on a redirected/piped stdin, or the readline interface being * closed elsewhere). Racing every `rl.question()` against this means a - * closed-before-answered stdin (e.g. `mcpi ... ; /** - * Pretty-print JSON for session `--format json`. + * Pretty-print JSON for connection `--format json`. * Unlike one-shot, this does **not** wrap in `{ result }` — the payload is the * MCP / admin object itself (convenient for scripting). */ -export function formatSessionJson(data: unknown): string { +export function formatConnectionJson(data: unknown): string { return JSON.stringify(data, null, 2) + "\n"; } -export type SessionWriteKind = +export type ConnectionWriteKind = | { kind: "rpc"; method: string; result: JsonObject; /** * Auto-collected by `runMethod` for `tools/call` + `--format json`. - * Session output ignores this side-channel (no `{ result, appInfo }` + * Connection output ignores this side-channel (no `{ result, appInfo }` * envelope); only `result` is printed. `--app-info` probes put the * info object in `result` itself. */ @@ -61,8 +61,8 @@ export type SessionWriteKind = | { kind: "stream-event"; data: unknown } | { kind: "servers/list"; servers: unknown[] } | { kind: "servers/show"; server: JsonObject } - | { kind: "sessions/list"; sessions: unknown[] } - | { kind: "session"; session: SessionInfo | JsonObject } + | { kind: "connections/list"; connections: unknown[] } + | { kind: "connection"; connection: ConnectionInfo | JsonObject } | { kind: "disconnect"; name: string } | { kind: "daemon/status"; status: JsonObject } | { kind: "daemon/stop"; result: JsonObject } @@ -95,25 +95,25 @@ export type SessionWriteKind = } | { kind: "generic"; data: unknown; title?: string }; -export type SessionWriteOpts = { +export type ConnectionWriteOpts = { format?: OutputFormat; /** Human-output styling; ignored for `--format json`. Defaults to plain. */ style?: Style; }; /** - * Write session CLI output honouring `--format text|json`. + * Write connection CLI output honouring `--format text|json`. * One-shot output paths are unchanged (`emitResult` / `writeFormattedResult`). */ -export async function writeSessionOutput( - opts: SessionWriteOpts, - payload: SessionWriteKind, +export async function writeConnectionOutput( + opts: ConnectionWriteOpts, + payload: ConnectionWriteKind, ): Promise { const format: OutputFormat = opts.format === "json" ? "json" : "text"; const style = opts.style ?? PLAIN; if (format === "json") { - await awaitableLog(formatSessionJson(jsonPayload(payload))); + await awaitableLog(formatConnectionJson(jsonPayload(payload))); await writeNdjsonSummary(payload); applyExitCodes(payload); return; @@ -136,7 +136,7 @@ export async function writeSessionOutput( * into `jq` still sees it and a `--format json` caller isn't left without one * just because the report itself is already structured. */ -async function writeNdjsonSummary(payload: SessionWriteKind): Promise { +async function writeNdjsonSummary(payload: ConnectionWriteKind): Promise { if (payload.kind === "ndjson" && payload.summary) { // Human-facing stderr line in both formats; may embed server-derived // names, so sanitize (see sanitize.ts). @@ -144,7 +144,7 @@ async function writeNdjsonSummary(payload: SessionWriteKind): Promise { } } -function jsonPayload(payload: SessionWriteKind): unknown { +function jsonPayload(payload: ConnectionWriteKind): unknown { switch (payload.kind) { case "rpc": // Pretty payload only — never the one-shot `{ result[, appInfo] }` wrap. @@ -157,10 +157,10 @@ function jsonPayload(payload: SessionWriteKind): unknown { return { servers: payload.servers }; case "servers/show": return payload.server; - case "sessions/list": - return { sessions: payload.sessions }; - case "session": - return payload.session; + case "connections/list": + return { connections: payload.connections }; + case "connection": + return payload.connection; case "disconnect": return { name: payload.name }; case "daemon/status": @@ -182,7 +182,7 @@ function jsonPayload(payload: SessionWriteKind): unknown { } } -function humanPayload(payload: SessionWriteKind, style: Style): string { +function humanPayload(payload: ConnectionWriteKind, style: Style): string { switch (payload.kind) { case "rpc": { if (asAppInfoProbe(payload.result)) { @@ -205,10 +205,10 @@ function humanPayload(payload: SessionWriteKind, style: Style): string { return formatServersListHuman(payload.servers, style); case "servers/show": return formatServerShowHuman(payload.server, style); - case "sessions/list": - return formatSessionsListHuman(payload.sessions, style); - case "session": - return formatSessionInfoHuman(payload.session as JsonObject, style); + case "connections/list": + return formatConnectionsListHuman(payload.connections, style); + case "connection": + return formatConnectionInfoHuman(payload.connection as JsonObject, style); case "disconnect": return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`; case "daemon/status": { @@ -216,13 +216,13 @@ function humanPayload(payload: SessionWriteKind, style: Style): string { if (s.running === false) { return String(s.message ?? "Daemon is not running."); } - const sessions = Array.isArray(s.sessions) - ? (s.sessions as unknown[]) + const connections = Array.isArray(s.connections) + ? (s.connections as unknown[]) : []; return [ `${style.bold("Daemon")} pid ${String(s.pid)}`, style.dim(`Socket: ${String(s.socketPath ?? "")}`), - formatSessionsListHuman(sessions, style), + formatConnectionsListHuman(connections, style), ].join("\n"); } case "daemon/stop": @@ -245,7 +245,7 @@ function humanPayload(payload: SessionWriteKind, style: Style): string { return formatEmaStatusHuman(payload.status, style); case "auth/ema-login": if (payload.result.alreadyLoggedIn) { - return `${style.green("Already signed in")} to \`${style.bold(payload.result.issuer)}\` ${style.dim("(use auth/ema-login --relogin for a fresh session)")}`; + return `${style.green("Already signed in")} to \`${style.bold(payload.result.issuer)}\` ${style.dim("(use auth/ema-login --relogin for a fresh connection)")}`; } return `${style.green("Signed in")} to \`${style.bold(payload.result.issuer)}\``; case "auth/ema-logout": @@ -274,10 +274,10 @@ function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined { return result as unknown as CliAppInfo; } -function applyExitCodes(payload: SessionWriteKind): void { +function applyExitCodes(payload: ConnectionWriteKind): void { if (payload.kind === "ndjson" && payload.exitCode) { // Report already written above; thrown last so it routes through the - // session CLI's single exit path, same as the one-shot CLI's + // connection CLI's single exit path, same as the one-shot CLI's // `consumeMethodOutcome` (Copilot). throw new CliExitCodeError(payload.exitCode, payload.summary ?? "", { code: diff --git a/clients/mcpi/src/session/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts similarity index 93% rename from clients/mcpi/src/session/format-human.ts rename to clients/daemon-cli/src/connection/format-human.ts index 0c8aeaa331..0685d0b09d 100644 --- a/clients/mcpi/src/session/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -1,5 +1,5 @@ /** - * Human-readable (markdown-ish) formatters for the session CLI. + * Human-readable (markdown-ish) formatters for the connection CLI. * Styling (color / bold / dim / OSC 8 links) is parameterized via {@link Style}. */ @@ -534,15 +534,15 @@ export function formatServersListHuman( const lines = [heading(style, `Servers (${servers.length}):`)]; for (const raw of servers) { const s = raw as JsonObject; - const sessionName = - typeof s.session === "string" && s.session.length > 0 - ? s.session + const connectionName = + typeof s.connection === "string" && s.connection.length > 0 + ? s.connection : undefined; - const sessionMark = sessionName - ? ` ${style.green(`@${sessionName}`)}${s.isMru === true ? style.green(" (MRU)") : ""}` + const connectionMark = connectionName + ? ` ${style.green(`@${connectionName}`)}${s.isMru === true ? style.green(" (MRU)") : ""}` : ""; lines.push( - `* ${code(style, String(s.name))} ${style.dim(`[${String(s.type)}]`)} ${style.dim(String(s.detail ?? ""))}${sessionMark}`, + `* ${code(style, String(s.name))} ${style.dim(`[${String(s.type)}]`)} ${style.dim(String(s.detail ?? ""))}${connectionMark}`, ); } if (servers.length === 0) lines.push(style.dim("(none)")); @@ -572,13 +572,13 @@ export function formatServerShowHuman( ].join("\n"); } -/** Format sessions/list. */ -export function formatSessionsListHuman( - sessions: unknown[], +/** Format connections/list. */ +export function formatConnectionsListHuman( + connections: unknown[], style: Style = PLAIN, ): string { - const lines = [heading(style, `Sessions (${sessions.length}):`)]; - for (const raw of sessions) { + const lines = [heading(style, `Connections (${connections.length}):`)]; + for (const raw of connections) { const s = raw as JsonObject; const mru = s.isMru === true ? style.green(" (MRU)") : ""; const era = @@ -589,26 +589,26 @@ export function formatSessionsListHuman( `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}`, ); } - if (sessions.length === 0) lines.push(style.dim("(none — connect first)")); + if (connections.length === 0) lines.push(style.dim("(none — connect first)")); return lines.join("\n"); } -/** Format a single session info (connect / sessions/use / sessions/show). */ -export function formatSessionInfoHuman( - session: JsonObject, +/** Format a single connection info (connect / connections/use / connections/show). */ +export function formatConnectionInfoHuman( + connection: JsonObject, style: Style = PLAIN, ): string { - const mru = session.isMru === true ? style.green(" (MRU)") : ""; + const mru = connection.isMru === true ? style.green(" (MRU)") : ""; const lines = [ - `${heading(style, "Session")} ${code(style, `@${String(session.name)}`)}${mru}`, - `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, + `${heading(style, "Connection")} ${code(style, `@${String(connection.name)}`)}${mru}`, + `Server: ${style.dim(String(connection.serverIdentity ?? ""))}`, ]; - // Connection details. `protocolEra` is now on every `SessionInfo` (#2298 - // follow-up), so it renders for plain `connect`/`sessions/use` results too; - // `protocolVersion` and everything below it are `sessions/show`-only. - const era = session.protocolEra; - const protocolVersion = session.protocolVersion; + // Connection details. `protocolEra` is now on every `ConnectionInfo` (#2298 + // follow-up), so it renders for plain `connect`/`connections/use` results too; + // `protocolVersion` and everything below it are `connections/show`-only. + const era = connection.protocolEra; + const protocolVersion = connection.protocolVersion; if (era !== undefined || protocolVersion !== undefined) { const versionSuffix = protocolVersion !== undefined ? ` (${String(protocolVersion)})` : ""; @@ -617,8 +617,8 @@ export function formatSessionInfoHuman( ); } // Authorization snapshot (connect-time; omitted for stdio / no-auth - // servers — see `SessionInfo.auth`). - const auth = session.auth as JsonObject | undefined; + // servers — see `ConnectionInfo.auth`). + const auth = connection.auth as JsonObject | undefined; if (auth !== undefined) { const method = auth.method === "ema" ? "EMA" : "OAuth"; const parts = [auth.authorized === true ? "authorized" : "not authorized"]; @@ -633,7 +633,7 @@ export function formatSessionInfoHuman( } lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`); } - const serverInfo = session.serverInfo as JsonObject | undefined; + const serverInfo = connection.serverInfo as JsonObject | undefined; if (serverInfo?.name !== undefined) { const version = serverInfo.version !== undefined ? ` v${String(serverInfo.version)}` : ""; @@ -641,21 +641,24 @@ export function formatSessionInfoHuman( `Server info: ${style.dim(`${String(serverInfo.name)}${version}`)}`, ); } - const capabilities = session.capabilities as JsonObject | undefined; + const capabilities = connection.capabilities as JsonObject | undefined; if (capabilities !== undefined) { const keys = Object.keys(capabilities); lines.push( `Capabilities: ${style.dim(keys.length > 0 ? keys.join(", ") : "(none)")}`, ); } - const supportedVersions = session.supportedVersions; + const supportedVersions = connection.supportedVersions; if (Array.isArray(supportedVersions) && supportedVersions.length > 0) { lines.push( `Supported versions: ${style.dim(supportedVersions.join(", "))}`, ); } - if (typeof session.instructions === "string" && session.instructions !== "") { - lines.push(`Instructions: ${style.dim(session.instructions)}`); + if ( + typeof connection.instructions === "string" && + connection.instructions !== "" + ) { + lines.push(`Instructions: ${style.dim(connection.instructions)}`); } return lines.join("\n"); diff --git a/clients/mcpi/src/session/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts similarity index 82% rename from clients/mcpi/src/session/mcp.ts rename to clients/daemon-cli/src/connection/mcp.ts index f9ebec1d3d..b53f97da36 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -22,9 +22,12 @@ import { type LoggingLevel } from "@modelcontextprotocol/client"; import { LoggingLevelSchema } from "@modelcontextprotocol/core"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { callDaemon, ensureDaemon } from "../daemon/index.js"; -import type { SessionInfo, SessionShowResult } from "../daemon/protocol.js"; +import type { + ConnectionInfo, + ConnectionShowResult, +} from "../daemon/protocol.js"; import { - annotateServerEntriesWithSessions, + annotateServerEntriesWithConnections, listServerEntries, showServerEntry, summarizeServerConfig, @@ -35,7 +38,7 @@ import { withConnectTimeout, } from "@inspector/cli/handlers/connect-timeout.js"; import { - SESSION_RPC_METHODS, + CONNECTION_RPC_METHODS, type MethodArgs, } from "@inspector/cli/handlers/method-types.js"; import { authorizeInFrontend } from "./authorize.js"; @@ -43,12 +46,12 @@ import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; import { resolveToolCallArgs } from "./parse-tool-args.js"; import { resolveCommandPath } from "./resolve-command.js"; import { - dispatchSessionRpc, - hoistAtSession, - requireExplicitSession, + dispatchConnectionRpc, + hoistAtConnection, + requireExplicitConnection, stripAt, } from "./dispatch.js"; -import { writeSessionOutput } from "./format-session.js"; +import { writeConnectionOutput } from "./format-connection.js"; import { createPrivateBinding, formatPrivateEnvExports, @@ -84,7 +87,7 @@ function isCommanderDisplayOnly(error: unknown): boolean { type GlobalOpts = { format?: OutputFormat; plain?: boolean; - session?: string; + connection?: string; catalog?: string; config?: string; storedAuthOnly?: boolean; @@ -100,29 +103,46 @@ function outOpts(opts: GlobalOpts) { const validLogLevels: LoggingLevel[] = Object.values(LoggingLevelSchema.enum); /** - * Session-first CLI entry (`mcpi`). Talks to the implicit session daemon over - * IPC for connect/disconnect/sessions and MCP RPCs; `servers/list` and + * `--conn` is a documented shorthand for `--connection`. Expanding it at the + * argv level keeps a single option registration (one help entry, one + * GlobalOpts field) instead of two options merged at every consumption site. + */ +export function expandConnAlias(argv: string[]): string[] { + return argv.map((arg) => + arg === "--conn" + ? "--connection" + : arg.startsWith("--conn=") + ? `--connection=${arg.slice("--conn=".length)}` + : arg, + ); +} + +/** + * Connection-first CLI entry (`mcpdo`). Talks to the implicit connection daemon over + * IPC for connect/disconnect/connections and MCP RPCs; `servers/list` and * `servers/show` are local (no daemon). */ export async function runMcp(argv?: string[]): Promise { const raw = argv ?? process.argv; - const { argv: rewritten, sessionFromAt } = hoistAtSession(raw); + const { argv: rewritten, connectionFromAt } = hoistAtConnection( + expandConnAlias(raw), + ); const program = new Command(); program.exitOverride((err) => { // Help/version already printed. Always throw so Commander does not // process.exit (which would tear down in-process tests); runMcp treats - // these as success. Bare `mcpi` uses code `commander.help` with exitCode 1 + // these as success. Bare `mcpdo` uses code `commander.help` with exitCode 1 // — must not reach handleError as an ErrorEnvelope. if (isCommanderDisplayOnly(err)) throw err; if (err.exitCode !== 0) throw err; }); program - .name("mcpi") + .name("mcpdo") .description( - "MCP Inspector session CLI — connect once, run many commands against a named session.\n\n" + - "Agent skill for mcpi: install with `npx skills add modelcontextprotocol/inspector --skill mcpi`, or see `agent-help` below.", + "MCP Inspector connection CLI — connect once, run many commands against a named connection.\n\n" + + "Agent skill for mcpdo: install with `npx skills add modelcontextprotocol/inspector --skill mcpdo`, or see `agent-help` below.", ) .helpOption("-h, --help", "Display help for command") .helpCommand("help [command]", "Display help for command") @@ -141,8 +161,8 @@ export async function runMcp(argv?: string[]): Promise { "Disable ANSI styling (color, bold/dim, hyperlinks) in human text output", ) .option( - "--session ", - "Session name (without required @). Overrides MRU / positional @name.", + "--connection ", + "Connection name (without required @). Overrides MRU / positional @name. `--conn` is a supported shorthand.", ) .option( "--catalog ", @@ -150,21 +170,21 @@ export async function runMcp(argv?: string[]): Promise { ) .option( "--config ", - "Read-only session config file (never written or seeded)", + "Read-only connection config file (never written or seeded)", ) .option( "--stored-auth-only", "Never start interactive OAuth; use the shared store if present, otherwise fail.", ); - if (sessionFromAt) { - program.setOptionValue("session", sessionFromAt); + if (connectionFromAt) { + program.setOptionValue("connection", connectionFromAt); } program .command("servers/list") .description( - "List catalog/config server entries (marks live sessions when the daemon is running; no MCP connection)", + "List catalog/config server entries (marks live connections when the daemon is running; no MCP connection)", ) .action(async () => { const opts = program.opts(); @@ -173,19 +193,19 @@ export async function runMcp(argv?: string[]): Promise { catalogPath: opts.catalog?.trim() || envCatalog, configPath: opts.config?.trim() || undefined, }); - let sessions: SessionInfo[] = []; + let connections: ConnectionInfo[] = []; try { - const result = await callDaemon<{ sessions: SessionInfo[] }>( - "sessions/list", + const result = await callDaemon<{ connections: ConnectionInfo[] }>( + "connections/list", {}, ); - sessions = result.sessions; + connections = result.connections; } catch (error) { if (!isDaemonUnreachable(error)) throw error; } - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "servers/list", - servers: annotateServerEntriesWithSessions(entries, sessions), + servers: annotateServerEntriesWithConnections(entries, connections), }); }); @@ -202,14 +222,14 @@ export async function runMcp(argv?: string[]): Promise { catalogPath: opts.catalog?.trim() || envCatalog, configPath: opts.config?.trim() || undefined, }); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "servers/show", server: entry, }); }); registerConnect(program); - registerSessionAdmin(program); + registerConnectionAdmin(program); registerAuthCommands(program); registerRpcCommands(program); // Keep infra commands last in --help (just before Commander's built-in help). @@ -228,7 +248,9 @@ export async function runMcp(argv?: string[]): Promise { function registerConnect(program: CommandType): void { program .command("connect") - .description("Connect a catalog entry or ad-hoc target as a named session") + .description( + "Connect a catalog entry or ad-hoc target as a named connection", + ) .argument( "[target...]", "Catalog entry name, or command/URL (use -- for command args)", @@ -294,7 +316,7 @@ function registerConnect(program: CommandType): void { "Elicitation capability to advertise: off, url, form, or both (default). " + "Overrides the catalog/config entry's elicitCapability; the only way to " + "set it for an ad-hoc target, which has no config entry of its own. Use " + - "off when the caller of mcpi can't handle an elicitation request, so the " + + "off when the caller of mcpdo can't handle an elicitation request, so the " + "server sees no elicitation capability and can fall back on its own.", (value: string) => { const valid: ElicitCapabilityMode[] = ["off", "url", "form", "both"]; @@ -317,14 +339,15 @@ function registerConnect(program: CommandType): void { ) .action(async (target: string[], cmdOpts) => { const opts = program.opts(); - const { name: positionalSession, rest } = splitSessionTarget(target); - const sessionName = - stripAt(opts.session) ?? - positionalSession ?? + const { name: positionalConnection, rest } = + splitConnectionTarget(target); + const connectionName = + stripAt(opts.connection) ?? + positionalConnection ?? cmdOpts.server?.trim() ?? rest[0]; - if (!sessionName) { + if (!connectionName) { throw new CliExitCodeError( EXIT_CODES.USAGE, "connect requires a catalog entry name, --server , or an ad-hoc target.", @@ -371,14 +394,14 @@ function registerConnect(program: CommandType): void { let serverConfig = selected.config; // A stdio config with no cwd would resolve relative commands and // relative paths against the DAEMON's cwd — whichever directory the - // first mcpi invocation happened to run from. Pin it to the caller's - // cwd, which is what `mcpi connect node ./server.js` means to the user. + // first mcpdo invocation happened to run from. Pin it to the caller's + // cwd, which is what `mcpdo connect node ./server.js` means to the user. // A cwd configured in the catalog/config entry (or --cwd) still wins. if (serverConfig.type === "stdio" && !serverConfig.cwd) { serverConfig = { ...serverConfig, cwd: process.cwd() }; } // Same staleness problem for bare command names: the daemon would look - // `node` up in the PATH of whichever mcpi invocation first spawned it. + // `node` up in the PATH of whichever mcpdo invocation first spawned it. // Resolve against the CALLER's PATH here so the daemon spawns exactly // the binary this shell would have run. if (serverConfig.type === "stdio") { @@ -402,7 +425,7 @@ function registerConnect(program: CommandType): void { cmdOpts.ema === true ? true : undefined, ); const { detail } = summarizeServerConfig(serverConfig); - const name = stripAt(sessionName)!; + const name = stripAt(connectionName)!; if (relogin && "url" in serverConfig && serverConfig.url) { await clearStoredAuthForRelogin(serverConfig.url); @@ -416,9 +439,9 @@ function registerConnect(program: CommandType): void { serverIdentity: detail, }; - let result: SessionInfo; + let result: ConnectionInfo; try { - result = await callDaemon("connect", connectParams, { + result = await callDaemon("connect", connectParams, { socketPath, }); } catch (error) { @@ -435,19 +458,19 @@ function registerConnect(program: CommandType): void { storedAuthOnly: false, }); // Interactive OAuth can run well past the daemon's idle timeout - // (60s, armed while it holds zero sessions) — a slow human login + // (60s, armed while it holds zero connections) — a slow human login // (SSO, MFA) can leave the daemon we ensured above already exited. // Re-ensure so the retry lands on a live daemon instead of a stale // socket; ensureDaemon() is a no-op when the existing one still // answers pings. const { socketPath: freshSocketPath } = await ensureDaemon(); - result = await callDaemon("connect", connectParams, { + result = await callDaemon("connect", connectParams, { socketPath: freshSocketPath, }); } - await writeSessionOutput(outOpts(opts), { - kind: "session", - session: result, + await writeConnectionOutput(outOpts(opts), { + kind: "connection", + connection: result, }); }); } @@ -461,7 +484,7 @@ function registerAuthCommands(program: CommandType): void { .action(async () => { const opts = program.opts(); const list = await listStoredAuth(); - await writeSessionOutput(outOpts(opts), { kind: "auth/list", list }); + await writeConnectionOutput(outOpts(opts), { kind: "auth/list", list }); }); program @@ -522,14 +545,14 @@ function registerAuthCommands(program: CommandType): void { } } const result = await clearAllStoredAuth(); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "auth/clear", result: { all: true, cleared: result.cleared }, }); return; } const result = await clearStoredAuth(key!); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "auth/clear", result: { url: result.url }, }); @@ -543,7 +566,7 @@ function registerAuthCommands(program: CommandType): void { .action(async () => { const opts = program.opts(); const status = await getEmaStatus(); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "auth/ema-status", status, }); @@ -552,7 +575,7 @@ function registerAuthCommands(program: CommandType): void { program .command("auth/ema-login") .description( - "Sign in to the enterprise IdP (EMA); subsequent connects to EMA servers mint tokens silently from this session", + "Sign in to the enterprise IdP (EMA); subsequent connects to EMA servers mint tokens silently from this connection", ) .option( "--relogin", @@ -561,7 +584,7 @@ function registerAuthCommands(program: CommandType): void { .action(async (cmdOpts) => { const opts = program.opts(); const result = await emaLogin({ relogin: cmdOpts.relogin === true }); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "auth/ema-login", result, }); @@ -575,55 +598,55 @@ function registerAuthCommands(program: CommandType): void { .action(async () => { const opts = program.opts(); const result = await emaLogout(); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "auth/ema-logout", result, }); }); } -function registerSessionAdmin(program: CommandType): void { +function registerConnectionAdmin(program: CommandType): void { program .command("disconnect") - .description("Disconnect a session (MRU when omitted on a TTY)") - .argument("[session]", "Optional @name / name to disconnect") - .action(async (sessionArg: string | undefined) => { + .description("Disconnect a connection (MRU when omitted on a TTY)") + .argument("[connection]", "Optional @name / name to disconnect") + .action(async (connectionArg: string | undefined) => { const opts = program.opts(); - const name = stripAt(opts.session) ?? stripAt(sessionArg); + const name = stripAt(opts.connection) ?? stripAt(connectionArg); const { socketPath } = await ensureDaemon(); const result = await callDaemon<{ name: string }>( "disconnect", { name, - requireExplicit: requireExplicitSession(), + requireExplicit: requireExplicitConnection(), }, { socketPath }, ); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "disconnect", name: result.name, }); }); program - .command("sessions/list") - .description("List open sessions (marks MRU); does not start the daemon") + .command("connections/list") + .description("List open connections (marks MRU); does not start the daemon") .action(async () => { const opts = program.opts(); try { - const result = await callDaemon<{ sessions: SessionInfo[] }>( - "sessions/list", + const result = await callDaemon<{ connections: ConnectionInfo[] }>( + "connections/list", {}, ); - await writeSessionOutput(outOpts(opts), { - kind: "sessions/list", - sessions: result.sessions, + await writeConnectionOutput(outOpts(opts), { + kind: "connections/list", + connections: result.connections, }); } catch (error) { if (isDaemonUnreachable(error)) { - await writeSessionOutput(outOpts(opts), { - kind: "sessions/list", - sessions: [], + await writeConnectionOutput(outOpts(opts), { + kind: "connections/list", + connections: [], }); return; } @@ -632,49 +655,49 @@ function registerSessionAdmin(program: CommandType): void { }); program - .command("sessions/use") - .description("Set the MRU session without an MCP RPC") - .argument("", "Session @name / name") - .action(async (sessionArg: string) => { + .command("connections/use") + .description("Set the MRU connection without an MCP RPC") + .argument("", "Connection @name / name") + .action(async (connectionArg: string) => { const opts = program.opts(); - const name = stripAt(opts.session) ?? stripAt(sessionArg); + const name = stripAt(opts.connection) ?? stripAt(connectionArg); if (!name) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "sessions/use requires a session name", + "connections/use requires a connection name", { code: "usage" }, ); } const { socketPath } = await ensureDaemon(); - const result = await callDaemon( - "sessions/use", + const result = await callDaemon( + "connections/use", { name }, { socketPath }, ); - await writeSessionOutput(outOpts(opts), { - kind: "session", - session: result, + await writeConnectionOutput(outOpts(opts), { + kind: "connection", + connection: result, }); }); program - .command("sessions/show") + .command("connections/show") .description( - "Show session + connection details: server info, capabilities, negotiated protocol era (defaults to MRU)", + "Show connection + connection details: server info, capabilities, negotiated protocol era (defaults to MRU)", ) - .argument("[session]", "Session @name / name (defaults to MRU)") - .action(async (sessionArg: string | undefined) => { + .argument("[connection]", "Connection @name / name (defaults to MRU)") + .action(async (connectionArg: string | undefined) => { const opts = program.opts(); - const name = stripAt(opts.session) ?? stripAt(sessionArg); + const name = stripAt(opts.connection) ?? stripAt(connectionArg); const { socketPath } = await ensureDaemon(); - const result = await callDaemon( - "sessions/show", - { name, requireExplicit: requireExplicitSession() }, + const result = await callDaemon( + "connections/show", + { name, requireExplicit: requireExplicitConnection() }, { socketPath }, ); - await writeSessionOutput(outOpts(opts), { - kind: "session", - session: result, + await writeConnectionOutput(outOpts(opts), { + kind: "connection", + connection: result, }); }); } @@ -684,18 +707,18 @@ function registerDaemonCommands(program: CommandType): void { daemon .command("status") - .description("Show daemon pid, socket, and sessions (does not start it)") + .description("Show daemon pid, socket, and connections (does not start it)") .action(async () => { const opts = program.opts(); try { const result = await callDaemon("daemon/status", {}); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "daemon/status", status: result as Record, }); } catch (error) { if (isDaemonUnreachable(error)) { - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "daemon/status", status: { running: false, @@ -710,18 +733,18 @@ function registerDaemonCommands(program: CommandType): void { daemon .command("stop") - .description("Stop the daemon and disconnect all sessions") + .description("Stop the daemon and disconnect all connections") .action(async () => { const opts = program.opts(); try { const result = await callDaemon("daemon/stop", {}); - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "daemon/stop", result: result as Record, }); } catch (error) { if (isDaemonUnreachable(error)) { - await writeSessionOutput(outOpts(opts), { + await writeConnectionOutput(outOpts(opts), { kind: "daemon/stop", result: { stopping: false, @@ -739,8 +762,8 @@ function registerPrivateCommand(program: CommandType): void { program .command("private") .description( - 'Print shell exports for a private daemon (eval "$(mcpi private)"). ' + - "Later mcpi commands in that shell use an isolated, token-gated daemon.", + 'Print shell exports for a private daemon (eval "$(mcpdo private)"). ' + + "Later mcpdo commands in that shell use an isolated, token-gated daemon.", ) .action(async () => { const binding = createPrivateBinding(); @@ -749,16 +772,16 @@ function registerPrivateCommand(program: CommandType): void { } /** - * Locates the repo-root `skills/mcpi/SKILL.md` relative to this module. - * Tries both the built (bundled single-file, `clients/mcpi/build/`) and - * source (`clients/mcpi/src/session/`) layouts, since the two sit at + * Locates the repo-root `skills/mcpdo/SKILL.md` relative to this module. + * Tries both the built (bundled single-file, `clients/daemon-cli/build/`) and + * source (`clients/daemon-cli/src/connection/`) layouts, since the two sit at * different depths from the repo root. */ function resolveAgentSkillPath(): string | undefined { const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ - path.resolve(here, "../../../skills/mcpi/SKILL.md"), - path.resolve(here, "../../../../skills/mcpi/SKILL.md"), + path.resolve(here, "../../../skills/mcpdo/SKILL.md"), + path.resolve(here, "../../../../skills/mcpdo/SKILL.md"), ]; return candidates.find((candidate) => existsSync(candidate)); } @@ -767,7 +790,7 @@ function registerAgentHelpCommand(program: CommandType): void { program .command("agent-help") .description( - "Print mcpi's SKILL.md content — a concise, agent-oriented guide for " + + "Print mcpdo's SKILL.md content — a concise, agent-oriented guide for " + "coding agents/LLMs (also the file `npx skills` installs). Use " + "--path to print its file location instead of its contents.", ) @@ -777,7 +800,7 @@ function registerAgentHelpCommand(program: CommandType): void { if (!skillPath) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "Could not locate skills/mcpi/SKILL.md relative to this install.", + "Could not locate skills/mcpdo/SKILL.md relative to this install.", { code: "agent_help_not_found" }, ); } @@ -790,10 +813,10 @@ function registerAgentHelpCommand(program: CommandType): void { } function registerRpcCommands(program: CommandType): void { - for (const method of SESSION_RPC_METHODS) { + for (const method of CONNECTION_RPC_METHODS) { const cmd = program .command(method) - .description(`MCP ${method} against the current session`); + .description(`MCP ${method} against the current connection`); cmd.option( "--metadata ", @@ -1018,11 +1041,11 @@ async function runRpc( methodArgs: MethodArgs, ): Promise { const opts = program.opts(); - await dispatchSessionRpc(method, methodArgs, { + await dispatchConnectionRpc(method, methodArgs, { format: opts.format, plain: opts.plain === true, - session: opts.session, - requireExplicit: requireExplicitSession(), + connection: opts.connection, + requireExplicit: requireExplicitConnection(), }); } @@ -1133,7 +1156,7 @@ function looksLikeUrl(value: string): boolean { return /^https?:\/\//i.test(value); } -function splitSessionTarget(target: string[]): { +function splitConnectionTarget(target: string[]): { name: string | undefined; rest: string[]; } { @@ -1143,4 +1166,4 @@ function splitSessionTarget(target: string[]): { return { name: undefined, rest: target }; } -export { hoistAtSession } from "./dispatch.js"; +export { hoistAtConnection } from "./dispatch.js"; diff --git a/clients/mcpi/src/session/parse-tool-args.ts b/clients/daemon-cli/src/connection/parse-tool-args.ts similarity index 98% rename from clients/mcpi/src/session/parse-tool-args.ts rename to clients/daemon-cli/src/connection/parse-tool-args.ts index 74315516ca..cb447805d0 100644 --- a/clients/mcpi/src/session/parse-tool-args.ts +++ b/clients/daemon-cli/src/connection/parse-tool-args.ts @@ -1,7 +1,7 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; /** - * Parse session `tools/call` positionals after the tool name: + * Parse connection `tools/call` positionals after the tool name: * - `key:=value` pairs (JSON-typed when the value parses as JSON, else string) * - a single inline JSON object (`{"message":"Foo"}`) */ diff --git a/clients/mcpi/src/session/private-env.ts b/clients/daemon-cli/src/connection/private-env.ts similarity index 92% rename from clients/mcpi/src/session/private-env.ts rename to clients/daemon-cli/src/connection/private-env.ts index f4b62e26ec..8f5b00ddac 100644 --- a/clients/mcpi/src/session/private-env.ts +++ b/clients/daemon-cli/src/connection/private-env.ts @@ -21,7 +21,7 @@ export function createPrivateBinding(): PrivateEnvBinding { } /** - * Shell exports for `eval "$(mcpi private)"` (POSIX sh / bash / zsh). + * Shell exports for `eval "$(mcpdo private)"` (POSIX sh / bash / zsh). */ export function formatPrivateEnvExports(binding: PrivateEnvBinding): string { return [ diff --git a/clients/mcpi/src/session/resolve-command.ts b/clients/daemon-cli/src/connection/resolve-command.ts similarity index 91% rename from clients/mcpi/src/session/resolve-command.ts rename to clients/daemon-cli/src/connection/resolve-command.ts index 75a556f6c6..b0cb0b7cbb 100644 --- a/clients/mcpi/src/session/resolve-command.ts +++ b/clients/daemon-cli/src/connection/resolve-command.ts @@ -5,7 +5,7 @@ import path from "node:path"; * Resolve a bare stdio command name to an absolute path using the CALLER's * `PATH`, before the config crosses the IPC boundary. * - * The daemon inherits the environment of whichever mcpi invocation first + * The daemon inherits the environment of whichever mcpdo invocation first * spawned it, so a bare `node` would otherwise be looked up in a stale * `PATH` (a different nvm version, a venv from another shell) — the daemon * could run a different binary than the one the user's shell would. @@ -13,7 +13,7 @@ import path from "node:path"; * environment across the boundary. * * Commands containing a path separator are returned unchanged: the daemon - * resolves those against the session cwd, which connect already pins to the + * resolves those against the connection cwd, which connect already pins to the * caller's cwd. Names not found on `PATH` are also returned unchanged so the * daemon's spawn error remains the user-visible failure. */ diff --git a/clients/mcpi/src/session/sanitize.ts b/clients/daemon-cli/src/connection/sanitize.ts similarity index 100% rename from clients/mcpi/src/session/sanitize.ts rename to clients/daemon-cli/src/connection/sanitize.ts diff --git a/clients/mcpi/src/session/stored-auth.ts b/clients/daemon-cli/src/connection/stored-auth.ts similarity index 100% rename from clients/mcpi/src/session/stored-auth.ts rename to clients/daemon-cli/src/connection/stored-auth.ts diff --git a/clients/mcpi/src/daemon/auth.ts b/clients/daemon-cli/src/daemon/auth.ts similarity index 100% rename from clients/mcpi/src/daemon/auth.ts rename to clients/daemon-cli/src/daemon/auth.ts diff --git a/clients/mcpi/src/daemon/client.ts b/clients/daemon-cli/src/daemon/client.ts similarity index 97% rename from clients/mcpi/src/daemon/client.ts rename to clients/daemon-cli/src/daemon/client.ts index 0812b58ed5..8c734ff3c7 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/daemon-cli/src/daemon/client.ts @@ -5,7 +5,7 @@ import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { getDaemonTokenFromEnv, readDaemonTokenFile } from "./auth.js"; import { encodeRequest } from "./framing.js"; import { getDaemonSocketPath } from "./paths.js"; -import { sanitizeText } from "../session/sanitize.js"; +import { sanitizeText } from "../connection/sanitize.js"; import type { DaemonOp, DaemonRequest, @@ -170,7 +170,7 @@ export async function callDaemon( fail( new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Cannot reach session daemon at ${socketPath}: ${err.message}`, + `Cannot reach connection daemon at ${socketPath}: ${err.message}`, { code: "daemon_unreachable" }, ), ); @@ -183,7 +183,7 @@ export async function callDaemon( fail( new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Session daemon closed the connection during '${op}'`, + `Connection daemon closed the connection during '${op}'`, { code: "daemon_unreachable" }, ), ); diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/daemon-cli/src/daemon/connections.ts similarity index 75% rename from clients/mcpi/src/daemon/sessions.ts rename to clients/daemon-cli/src/daemon/connections.ts index 566c3cb9b3..8c0f741794 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -37,31 +37,31 @@ import { } from "@inspector/core/auth/index.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import type { SessionAuthInfo, SessionInfo } from "./protocol.js"; +import type { ConnectionAuthInfo, ConnectionInfo } from "./protocol.js"; -const SESSION_CLIENT_NAME = "inspector-cli"; +const CONNECTION_CLIENT_NAME = "inspector-cli"; -/** Default idle timeout after the last session disconnects (~60s). */ +/** Default idle timeout after the last connection disconnects (~60s). */ export const DEFAULT_IDLE_MS = 60_000; -type LiveSession = { +type LiveConnection = { name: string; serverIdentity: string; connectedAt: number; lastAccessedAt: number; client: InspectorClient; - /** Retained for `sessions/show`'s live auth recompute. */ + /** Retained for `connections/show`'s live auth recompute. */ serverConfig: MCPServerConfig; serverSettings?: InspectorServerSettings; - /** Connect-time snapshot (see {@link SessionInfo.auth}). */ - auth?: SessionAuthInfo; + /** Connect-time snapshot (see {@link ConnectionInfo.auth}). */ + auth?: ConnectionAuthInfo; }; /** - * In-memory registry of live MCP sessions owned by the daemon. + * In-memory registry of live MCP connections owned by the daemon. */ -export class SessionRegistry { - private readonly sessions = new Map(); +export class ConnectionRegistry { + private readonly connections = new Map(); private mruName: string | null = null; private idleTimer: ReturnType | null = null; /** Absolute deadline for idle shutdown while the timer is armed. */ @@ -73,24 +73,24 @@ export class SessionRegistry { this.idleMs = idleMs; } - /** Register a callback invoked when the idle timer fires with no sessions. */ + /** Register a callback invoked when the idle timer fires with no connections. */ setIdleHandler(handler: (() => void) | null): void { this.onIdle = handler; } /** - * Arm the idle shutdown timer when there are no sessions. + * Arm the idle shutdown timer when there are no connections. * Called at daemon start so a spawn that never connects still self-reaps, * and after a failed connect that left the registry empty. */ armIdleTimerIfEmpty(): void { - if (this.sessions.size === 0) { + if (this.connections.size === 0) { this.armIdleTimer(); } } - list(): SessionInfo[] { - return [...this.sessions.values()] + list(): ConnectionInfo[] { + return [...this.connections.values()] .map((s) => ({ name: s.name, serverIdentity: s.serverIdentity, @@ -107,88 +107,88 @@ export class SessionRegistry { return this.mruName; } - sessionCount(): number { - return this.sessions.size; + connectionCount(): number { + return this.connections.size; } /** - * Resolve a session by explicit name or MRU. Throws {@link CliExitCodeError} + * Resolve a connection by explicit name or MRU. Throws {@link CliExitCodeError} * when missing / ambiguous under CI rules. */ resolve( name: string | undefined, requireExplicit: boolean | undefined, - ): LiveSession { + ): LiveConnection { if (!name) { if (requireExplicit) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "Explicit --session / @name is required in non-interactive mode.", - { code: "session_required" }, + "Explicit --connection / @name is required in non-interactive mode.", + { code: "connection_required" }, ); } if (!this.mruName) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "No open sessions. Connect first (e.g. mcpi servers/list, mcpi connect ).", - { code: "no_session" }, + "No open connections. Connect first (e.g. mcpdo servers/list, mcpdo connect ).", + { code: "no_connection" }, ); } name = this.mruName; } - const session = this.sessions.get(name); - if (!session) { + const connection = this.connections.get(name); + if (!connection) { throw new CliExitCodeError( EXIT_CODES.USAGE, - `Session '${name}' not found. Use mcpi sessions/list.`, - { code: "session_not_found" }, + `Connection '${name}' not found. Use mcpdo connections/list.`, + { code: "connection_not_found" }, ); } - return session; + return connection; } touch(name: string): void { - const session = this.sessions.get(name); - if (!session) return; - session.lastAccessedAt = Date.now(); + const connection = this.connections.get(name); + if (!connection) return; + connection.lastAccessedAt = Date.now(); this.mruName = name; this.clearIdleTimer(); } /** - * Resolve a session for an RPC/stream/show, touch MRU, and return the - * live session (name/serverIdentity/timestamps plus the client). + * Resolve a connection for an RPC/stream/show, touch MRU, and return the + * live connection (name/serverIdentity/timestamps plus the client). */ - sessionFor( + connectionFor( name: string | undefined, requireExplicit: boolean | undefined, - ): LiveSession { - const session = this.resolve(name, requireExplicit); - this.touch(session.name); - return session; + ): LiveConnection { + const connection = this.resolve(name, requireExplicit); + this.touch(connection.name); + return connection; } /** - * Resolve a session for an RPC/stream, touch MRU, and return its client. + * Resolve a connection for an RPC/stream, touch MRU, and return its client. */ clientFor( name: string | undefined, requireExplicit: boolean | undefined, ): InspectorClient { - return this.sessionFor(name, requireExplicit).client; + return this.connectionFor(name, requireExplicit).client; } - use(name: string): SessionInfo { - const session = this.resolve(name, true); - this.touch(session.name); + use(name: string): ConnectionInfo { + const connection = this.resolve(name, true); + this.touch(connection.name); return { - name: session.name, - serverIdentity: session.serverIdentity, - connectedAt: session.connectedAt, - lastAccessedAt: session.lastAccessedAt, + name: connection.name, + serverIdentity: connection.serverIdentity, + connectedAt: connection.connectedAt, + lastAccessedAt: connection.lastAccessedAt, isMru: true, - protocolEra: session.client.getProtocolEra(), - ...(session.auth && { auth: session.auth }), + protocolEra: connection.client.getProtocolEra(), + ...(connection.auth && { auth: connection.auth }), }; } @@ -197,11 +197,11 @@ export class SessionRegistry { serverConfig: MCPServerConfig; serverSettings?: InspectorServerSettings; serverIdentity: string; - }): Promise { + }): Promise { this.clearIdleTimer(); try { - if (this.sessions.has(params.name)) { + if (this.connections.has(params.name)) { // Reconnect: tear down the previous client first. await this.disconnect(params.name, false); } @@ -210,7 +210,7 @@ export class SessionRegistry { // Drop the daemon's cached store so this connect re-reads disk. resetNodeOAuthStorageCache(); - const client = await createSessionClient( + const client = await createConnectionClient( params.serverConfig, params.serverSettings, ); @@ -219,7 +219,7 @@ export class SessionRegistry { await client.connect(); } catch (error) { await safeDisconnect(client); - if (isSessionAuthRequiredError(error)) { + if (isConnectionAuthRequiredError(error)) { throw new CliExitCodeError( EXIT_CODES.AUTH_REQUIRED, error instanceof Error ? error.message : String(error), @@ -230,8 +230,8 @@ export class SessionRegistry { } const now = Date.now(); - const auth = await getSessionAuthInfo(client); - this.sessions.set(params.name, { + const auth = await getConnectionAuthInfo(client); + this.connections.set(params.name, { name: params.name, serverIdentity: params.serverIdentity, connectedAt: now, @@ -253,8 +253,8 @@ export class SessionRegistry { ...(auth && { auth }), }; } catch (error) { - // Any failure after clearIdleTimer (createSessionClient, reconnect - // disconnect, client.connect, …) must re-arm so a session-less daemon + // Any failure after clearIdleTimer (createConnectionClient, reconnect + // disconnect, client.connect, …) must re-arm so a connection-less daemon // still self-reaps. this.armIdleTimerIfEmpty(); throw error; @@ -265,25 +265,25 @@ export class SessionRegistry { name: string | undefined, requireExplicit: boolean | undefined, ): Promise<{ name: string }> { - const session = this.resolve(name, requireExplicit); - const sessionName = session.name; - this.sessions.delete(sessionName); - if (this.mruName === sessionName) { - // Promote the next most-recently-accessed session, if any. - const remaining = [...this.sessions.values()].sort( + const connection = this.resolve(name, requireExplicit); + const connectionName = connection.name; + this.connections.delete(connectionName); + if (this.mruName === connectionName) { + // Promote the next most-recently-accessed connection, if any. + const remaining = [...this.connections.values()].sort( (a, b) => b.lastAccessedAt - a.lastAccessedAt, ); this.mruName = remaining[0]?.name ?? null; } - await safeDisconnect(session.client); - if (this.sessions.size === 0) { + await safeDisconnect(connection.client); + if (this.connections.size === 0) { this.armIdleTimer(); } - return { name: sessionName }; + return { name: connectionName }; } async disconnectAll(): Promise { - const names = [...this.sessions.keys()]; + const names = [...this.connections.keys()]; for (const name of names) { await this.disconnect(name, false); } @@ -297,7 +297,7 @@ export class SessionRegistry { this.idleTimer = setTimeout(() => { this.idleTimer = null; this.idleDeadline = null; - if (this.sessions.size === 0) { + if (this.connections.size === 0) { this.onIdle?.(); } }, this.idleMs); @@ -326,7 +326,7 @@ export class SessionRegistry { * not a hard ErrorEnvelope. Includes SDK token-exchange mistakes that happen when * stored creds need a full re-auth. */ -export function isSessionAuthRequiredError(error: unknown): boolean { +export function isConnectionAuthRequiredError(error: unknown): boolean { if ( error instanceof AuthRecoveryRequiredError || isUnauthorizedError(error) @@ -335,7 +335,7 @@ export function isSessionAuthRequiredError(error: unknown): boolean { } // EMA misconfiguration (no/disabled install-level IdP) must surface via the // front-end too: authorizeInFrontend re-hits it in-process and maps it to - // actionable mcpi guidance, instead of this daemon relaying the web-centric + // actionable mcpdo guidance, instead of this daemon relaying the web-centric // core message in an opaque error envelope. if (isEmaClientNotConfiguredError(error)) { return true; @@ -344,7 +344,7 @@ export function isSessionAuthRequiredError(error: unknown): boolean { return ( /prepareTokenRequest\(\) or authorizationCode is required/i.test(message) || /redirectUrl is required for authorization_code/i.test(message) || - /No code verifier saved for session/i.test(message) + /No code verifier saved for connection/i.test(message) ); } @@ -352,7 +352,7 @@ export function isSessionAuthRequiredError(error: unknown): boolean { * Maps a persisted/overridden `elicitCapability` mode onto the `elicit` shape * `InspectorClient` expects. Absence reads back as {@link * DEFAULT_ELICIT_CAPABILITY} (`"both"`), matching the pre-#1783 hardcoded - * default so existing sessions keep behaving the same until a caller opts + * default so existing connections keep behaving the same until a caller opts * into something narrower via `--elicit` or a catalog entry's * `elicitCapability` field. */ @@ -373,9 +373,9 @@ export function elicitCapabilityToClientOption( /** * Project the core `OAuthConnectionState` down to the slim - * {@link SessionAuthInfo} reported on `SessionInfo`. + * {@link ConnectionAuthInfo} reported on `ConnectionInfo`. */ -function projectAuthState(state: OAuthConnectionState): SessionAuthInfo { +function projectAuthState(state: OAuthConnectionState): ConnectionAuthInfo { return { method: state.protocol === "ema" ? "ema" : "oauth", authorized: state.authorized, @@ -388,13 +388,13 @@ function projectAuthState(state: OAuthConnectionState): SessionAuthInfo { /** * Connect-time auth snapshot, read through the live client's own storage. * Undefined for stdio servers and HTTP servers that never engaged OAuth - * (`getOAuthState()` returns undefined for both), so no-auth sessions simply + * (`getOAuthState()` returns undefined for both), so no-auth connections simply * omit the field. Best-effort: a storage read failure must never fail the * connect that already succeeded. */ -export async function getSessionAuthInfo( +export async function getConnectionAuthInfo( client: InspectorClient, -): Promise { +): Promise { let state; try { state = await client.getOAuthState(); @@ -406,7 +406,7 @@ export async function getSessionAuthInfo( } /** - * Live auth snapshot for `sessions/show`, read from *disk* rather than the + * Live auth snapshot for `connections/show`, read from *disk* rather than the * client's storage. `NodeOAuthStorage` is load-once/memory-authoritative, so * the live client never observes cross-process changes to `oauth.json` (an * `auth/clear`, `auth/ema-logout`, or a web-client re-auth) — a fresh storage @@ -415,12 +415,12 @@ export async function getSessionAuthInfo( * Best-effort: any failure falls back to the connect-time snapshot's absence * semantics (undefined). */ -export async function getLiveSessionAuthInfo(session: { +export async function getLiveConnectionAuthInfo(connection: { serverConfig: MCPServerConfig; serverSettings?: InspectorServerSettings; -}): Promise { +}): Promise { try { - const config = session.serverConfig; + const config = connection.serverConfig; if (!isOAuthCapableServerConfig(config)) return undefined; const serverUrl = "url" in config ? config.url : undefined; if (typeof serverUrl !== "string" || serverUrl === "") return undefined; @@ -429,7 +429,7 @@ export async function getLiveSessionAuthInfo(session: { const clientConfig = await loadRunnerClientConfig({}); const authOptions = buildRunnerClientAuthOptions( clientConfig, - session.serverSettings, + connection.serverSettings, {}, ); const oauthConfig = authOptions.oauth ?? {}; @@ -453,7 +453,7 @@ export async function getLiveSessionAuthInfo(session: { } } -async function createSessionClient( +async function createConnectionClient( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, ): Promise { @@ -488,7 +488,7 @@ async function createSessionClient( return new InspectorClient(serverConfig, { environment, clientIdentity: { - name: SESSION_CLIENT_NAME, + name: CONNECTION_CLIENT_NAME, version: readInspectorVersion(import.meta.url), }, initialLoggingLevel: "debug", diff --git a/clients/mcpi/src/daemon/elicitation-bridge.ts b/clients/daemon-cli/src/daemon/elicitation-bridge.ts similarity index 100% rename from clients/mcpi/src/daemon/elicitation-bridge.ts rename to clients/daemon-cli/src/daemon/elicitation-bridge.ts diff --git a/clients/mcpi/src/daemon/ensure.ts b/clients/daemon-cli/src/daemon/ensure.ts similarity index 95% rename from clients/mcpi/src/daemon/ensure.ts rename to clients/daemon-cli/src/daemon/ensure.ts index ea9c08886e..76de22a4cb 100644 --- a/clients/mcpi/src/daemon/ensure.ts +++ b/clients/daemon-cli/src/daemon/ensure.ts @@ -46,7 +46,7 @@ export function resolveDaemonScriptPath(): string { builds, and fs.existsSync cannot be spied in this ESM package under vitest. */ throw new CliExitCodeError( EXIT_CODES.USAGE, - `Session daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/mcpi.`, + `Connection daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/daemon-cli.`, { code: "daemon_not_built" }, ); } @@ -94,7 +94,7 @@ async function waitForDaemon( const logTail = readLogTail(logPath); throw new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Timed out waiting for session daemon at ${socketPath}` + + `Timed out waiting for connection daemon at ${socketPath}` + (logTail ? `\nDaemon log (${logPath}):\n${logTail}` : ""), { code: "daemon_start_timeout" }, ); @@ -112,7 +112,7 @@ export function readLogTail(logPath: string, maxLines = 10): string { } /** - * Ensure a session daemon is running for the current {@link getDaemonDir}. + * Ensure a connection daemon is running for the current {@link getDaemonDir}. * Auto-spawns a detached Node process when the socket is not reachable. * * When `MCP_INSPECTOR_DAEMON_TOKEN` is set (private mode), the child inherits @@ -140,7 +140,7 @@ export async function ensureDaemon(options?: { // Any ping failure here (daemon_auth_failed, timeout, protocol error) // must fail loudly: unlinking and respawning would let a caller with the // wrong token (or none) silently replace a live private daemon and - // orphan its sessions. Only a socket nothing is listening on — the + // orphan its connections. Only a socket nothing is listening on — the // unreachable path below — is stale, and the spawned daemon itself // removes it after a connect probe (removeStaleDaemonSocket). token ??= readDaemonTokenFile(dir); diff --git a/clients/mcpi/src/daemon/framing.ts b/clients/daemon-cli/src/daemon/framing.ts similarity index 100% rename from clients/mcpi/src/daemon/framing.ts rename to clients/daemon-cli/src/daemon/framing.ts diff --git a/clients/mcpi/src/daemon/index.ts b/clients/daemon-cli/src/daemon/index.ts similarity index 87% rename from clients/mcpi/src/daemon/index.ts rename to clients/daemon-cli/src/daemon/index.ts index 7c0efe6856..3b55d12fa7 100644 --- a/clients/mcpi/src/daemon/index.ts +++ b/clients/daemon-cli/src/daemon/index.ts @@ -29,12 +29,12 @@ export type { DaemonStatus, RpcParams, RpcResult, - SessionInfo, - SessionNameParams, + ConnectionInfo, + ConnectionNameParams, } from "./protocol.js"; export { DaemonServer } from "./server.js"; export { DEFAULT_IDLE_MS, - isSessionAuthRequiredError, - SessionRegistry, -} from "./sessions.js"; + isConnectionAuthRequiredError, + ConnectionRegistry, +} from "./connections.js"; diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/daemon-cli/src/daemon/ipc-glue.ts similarity index 98% rename from clients/mcpi/src/daemon/ipc-glue.ts rename to clients/daemon-cli/src/daemon/ipc-glue.ts index 8d1afc96af..e793ba5335 100644 --- a/clients/mcpi/src/daemon/ipc-glue.ts +++ b/clients/daemon-cli/src/daemon/ipc-glue.ts @@ -196,7 +196,7 @@ export async function removeStaleDaemonSocket( const live = await canConnect(socketPath); if (live) { throw new Error( - `Daemon already running at ${socketPath}. Use mcpi daemon stop first.`, + `Daemon already running at ${socketPath}. Use mcpdo daemon stop first.`, ); } try { diff --git a/clients/mcpi/src/daemon/paths.ts b/clients/daemon-cli/src/daemon/paths.ts similarity index 93% rename from clients/mcpi/src/daemon/paths.ts rename to clients/daemon-cli/src/daemon/paths.ts index 02ab25aefe..75ea566b4a 100644 --- a/clients/mcpi/src/daemon/paths.ts +++ b/clients/daemon-cli/src/daemon/paths.ts @@ -34,17 +34,17 @@ export function getDaemonDir(): string { * Create a new private daemon directory (mode `0700`). Does not start the * daemon. * - * Lives under `$TMPDIR/mcpi-//`, not `~/.mcp-inspector`: `sun_path` + * Lives under `$TMPDIR/mcp-conn-//`, not `~/.mcp-inspector`: `sun_path` * caps Unix socket paths at 104 bytes on macOS (108 on Linux), and the tmp * dir is short on every platform (macOS's per-user `/var/folders/...` is the * long case, and even that fits with the 8-char id). The parent - * `mcpi-` dir is also created 0700 so the layout never depends on the + * `mcp-conn-` dir is also created 0700 so the layout never depends on the * platform's default tmp permissions. */ export function createPrivateDaemonDir(): string { /* v8 ignore next 2 -- getuid is missing only on Windows */ const uid = typeof process.getuid === "function" ? process.getuid() : "u"; - const root = path.join(os.tmpdir(), `mcpi-${uid}`); + const root = path.join(os.tmpdir(), `mcp-conn-${uid}`); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); const id = randomBytes(4).toString("hex"); const dir = path.join(root, id); @@ -97,7 +97,7 @@ export function assertSocketPathWithinLimit(socketPath: string): void { const bytes = Buffer.byteLength(socketPath); if (bytes > limit) { throw new Error( - `Session daemon socket path is too long for this platform ` + + `Connection daemon socket path is too long for this platform ` + `(${bytes} bytes > ${limit}): ${socketPath}. ` + `Point MCP_INSPECTOR_DAEMON_DIR (or MCP_STORAGE_DIR) at a shorter directory.`, ); diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts similarity index 79% rename from clients/mcpi/src/daemon/protocol.ts rename to clients/daemon-cli/src/daemon/protocol.ts index 1b9a0fd3c2..8acfe16b16 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -13,14 +13,14 @@ import type { ServerCapabilities, } from "@modelcontextprotocol/client"; -/** Operations the session daemon accepts over IPC. */ +/** Operations the connection daemon accepts over IPC. */ export type DaemonOp = | "ping" | "connect" | "disconnect" - | "sessions/list" - | "sessions/use" - | "sessions/show" + | "connections/list" + | "connections/use" + | "connections/show" | "daemon/status" | "daemon/stop" | "rpc" @@ -30,25 +30,25 @@ export type ConnectParams = { name: string; serverConfig: MCPServerConfig; serverSettings?: InspectorServerSettings; - /** Human-readable server identity for `sessions/list`. */ + /** Human-readable server identity for `connections/list`. */ serverIdentity: string; }; -export type SessionNameParams = { - /** Omit to target the MRU session (TTY). */ +export type ConnectionNameParams = { + /** Omit to target the MRU connection (TTY). */ name?: string; /** - * When true (non-TTY / CI), omit is an error — require an explicit session. + * When true (non-TTY / CI), omit is an error — require an explicit connection. * Front-end sets this from `!process.stdin.isTTY` (not stdout — keying off - * stdin lets piping output, e.g. `mcpi tools/list | jq`, still use MRU when + * stdin lets piping output, e.g. `mcpdo tools/list | jq`, still use MRU when * a human is at the keyboard) unless opted out via - * `MCP_ALLOW_DEFAULT_SESSION=1`. + * `MCP_ALLOW_DEFAULT_CONNECTION=1`. */ requireExplicit?: boolean; }; -/** Params for `rpc` / `stream` — session targeting plus method args. */ -export type RpcParams = SessionNameParams & +/** Params for `rpc` / `stream` — connection targeting plus method args. */ +export type RpcParams = ConnectionNameParams & MethodArgs & { method: string; }; @@ -64,7 +64,7 @@ export type DaemonRequest = { token?: string; params?: | ConnectParams - | SessionNameParams + | ConnectionNameParams | RpcParams | Record; }; @@ -126,12 +126,12 @@ export type ElicitationResponseFrame = { }; /** - * Slim connect-time snapshot of a session's authorization, projected from the - * core `OAuthConnectionState` (see {@link SessionInfo.auth}). Absent entirely + * Slim connect-time snapshot of a connection's authorization, projected from the + * core `OAuthConnectionState` (see {@link ConnectionInfo.auth}). Absent entirely * for stdio servers and HTTP servers that never engaged OAuth — cleaner than * reporting "none" for every local server. */ -export type SessionAuthInfo = { +export type ConnectionAuthInfo = { method: "oauth" | "ema"; /** Whether tokens for this server are present in storage. */ authorized: boolean; @@ -143,40 +143,40 @@ export type SessionAuthInfo = { idpSession?: "none" | "logged_in" | "expired"; }; -export type SessionInfo = { +export type ConnectionInfo = { name: string; serverIdentity: string; connectedAt: number; lastAccessedAt: number; isMru: boolean; /** - * Negotiated era for this session's connection — legacy `initialize` vs. + * Negotiated era for this connection's connection — legacy `initialize` vs. * modern `server/discover` (#2298 follow-up). Present everywhere a live - * session is reported (`connect`, `sessions/list`, `sessions/use`), not - * just `sessions/show`, so a user with several open sessions can see which + * connection is reported (`connect`, `connections/list`, `connections/use`), not + * just `connections/show`, so a user with several open connections can see which * era each negotiated without querying them one at a time. Absent only if * the client hasn't connected (never observed in practice — every code - * path constructing a `SessionInfo` does so from an already-connected - * session). + * path constructing a `ConnectionInfo` does so from an already-connected + * connection). */ protocolEra?: ProtocolEra; /** * Authorization snapshot. Like `protocolEra`, present everywhere a live - * session is reported so both humans and agents can see *how* a session is + * connection is reported so both humans and agents can see *how* a connection is * authenticated (OAuth vs. EMA, authorized or not) without a separate * query. Freshness varies by op: `connect` computes it right after the - * connection succeeds; `sessions/list` and `sessions/use` reuse that - * connect-time value; `sessions/show` recomputes it live *from disk* so it + * connection succeeds; `connections/list` and `connections/use` reuse that + * connect-time value; `connections/show` recomputes it live *from disk* so it * reflects the current persisted state (e.g. after `auth/clear` or - * `auth/ema-logout`, even from another process). Note a live session may + * `auth/ema-logout`, even from another process). Note a live connection may * keep working on its in-memory tokens after storage was cleared — `show` * reports the persisted state, matching `auth/ema-status`. */ - auth?: SessionAuthInfo; + auth?: ConnectionAuthInfo; }; /** - * `sessions/show` result: daemon bookkeeping ({@link SessionInfo}, which as of + * `connections/show` result: daemon bookkeeping ({@link ConnectionInfo}, which as of * #2298 already carries `protocolEra`) plus the live MCP connection state — * era-agnostic (`serverInfo`/`capabilities`/`instructions`/`protocolVersion` * are populated the same way whether they came from a legacy `initialize` @@ -184,7 +184,7 @@ export type SessionInfo = { * only set when the connect actually probed `server/discover`, i.e. * `auto`/`modern`). */ -export type SessionShowResult = SessionInfo & { +export type ConnectionShowResult = ConnectionInfo & { serverInfo?: Implementation; protocolVersion?: string; capabilities?: ServerCapabilities; @@ -195,7 +195,7 @@ export type SessionShowResult = SessionInfo & { export type DaemonStatus = { pid: number; socketPath: string; - sessions: SessionInfo[]; + connections: ConnectionInfo[]; idleMs: number | null; }; @@ -211,7 +211,7 @@ export type RpcResult = lines: unknown[]; /** * `skills/list --verify` / `skills/get --verify` one-line stderr - * verdict (#2248). Carried across the daemon socket so the session CLI + * verdict (#2248). Carried across the daemon socket so the connection CLI * can report the same summary the one-shot CLI does, rather than * silently dropping it the way an earlier pass through this file did. */ diff --git a/clients/mcpi/src/daemon/run.ts b/clients/daemon-cli/src/daemon/run.ts similarity index 81% rename from clients/mcpi/src/daemon/run.ts rename to clients/daemon-cli/src/daemon/run.ts index 1f7af08feb..f74b60c4ab 100644 --- a/clients/mcpi/src/daemon/run.ts +++ b/clients/daemon-cli/src/daemon/run.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Session daemon entrypoint. Spawned detached by {@link ensureDaemon}. - * Optional foreground `mcpi daemon run` is not shipped yet (see v2_cli_v2.md). + * Connection daemon entrypoint. Spawned detached by {@link ensureDaemon}. + * Optional foreground `mcpdo daemon run` is not shipped yet (see v2_cli_v2.md). */ import { DaemonServer } from "./server.js"; import { generateDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; @@ -19,7 +19,7 @@ async function main(): Promise { }, }); - // Never keep the cwd of whichever mcpi invocation happened to spawn this + // Never keep the cwd of whichever mcpdo invocation happened to spawn this // daemon: connects would resolve relative stdio paths against it (and pin // the directory against unmounting). The front end always sends an // explicit cwd for stdio servers, so the daemon's own cwd is inert. @@ -37,6 +37,6 @@ async function main(): Promise { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`mcpi daemon: ${message}\n`); + process.stderr.write(`mcpdo daemon: ${message}\n`); process.exit(1); }); diff --git a/clients/mcpi/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts similarity index 89% rename from clients/mcpi/src/daemon/server.ts rename to clients/daemon-cli/src/daemon/server.ts index 4b5898e8b8..558b403b26 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -30,14 +30,14 @@ import type { DaemonStatus, RpcParams, RpcResult, - SessionNameParams, - SessionShowResult, + ConnectionNameParams, + ConnectionShowResult, } from "./protocol.js"; import { DEFAULT_IDLE_MS, - getLiveSessionAuthInfo, - SessionRegistry, -} from "./sessions.js"; + getLiveConnectionAuthInfo, + ConnectionRegistry, +} from "./connections.js"; /** * Default channel used when a caller doesn't wire a real one (in-process @@ -69,10 +69,10 @@ export type DaemonServerOptions = { }; /** - * Unix-socket NDJSON daemon that owns {@link SessionRegistry}. + * Unix-socket NDJSON daemon that owns {@link ConnectionRegistry}. */ export class DaemonServer { - readonly registry: SessionRegistry; + readonly registry: ConnectionRegistry; readonly socketPath: string; readonly lockPath: string; readonly dir: string; @@ -86,7 +86,7 @@ export class DaemonServer { this.socketPath = getDaemonSocketPath(this.dir); this.lockPath = getDaemonLockPath(this.dir); this.requiredToken = options.requiredToken ?? getDaemonTokenFromEnv(); - this.registry = new SessionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); + this.registry = new ConnectionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); this.onShutdown = options.onShutdown ?? null; this.registry.setIdleHandler(() => { void this.stop("idle"); @@ -138,7 +138,7 @@ export class DaemonServer { // Unsupported on some platforms (e.g. Windows named pipes). } - // Session-less spawn (e.g. ensureDaemon from tools/list with no sessions) + // Connection-less spawn (e.g. ensureDaemon from tools/list with no connections) // must still self-reap — idle was previously only armed after disconnect. this.registry.armIdleTimerIfEmpty(); } catch (error) { @@ -171,7 +171,7 @@ export class DaemonServer { return { pid: process.pid, socketPath: this.socketPath, - sessions: this.registry.list(), + connections: this.registry.list(), idleMs: this.registry.idleRemainingMs(), }; } @@ -253,7 +253,7 @@ export class DaemonServer { }; } case "disconnect": { - const params = (request.params ?? {}) as SessionNameParams; + const params = (request.params ?? {}) as ConnectionNameParams; return { response: { id: request.id, @@ -265,20 +265,20 @@ export class DaemonServer { }, }; } - case "sessions/list": + case "connections/list": return { response: { id: request.id, ok: true, - result: { sessions: this.registry.list() }, + result: { connections: this.registry.list() }, }, }; - case "sessions/use": { - const params = (request.params ?? {}) as SessionNameParams; + case "connections/use": { + const params = (request.params ?? {}) as ConnectionNameParams; if (!params.name) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "sessions/use requires a session name", + "connections/use requires a connection name", { code: "invalid_params" }, ); } @@ -290,23 +290,23 @@ export class DaemonServer { }, }; } - case "sessions/show": { - const params = (request.params ?? {}) as SessionNameParams; - const session = this.registry.sessionFor( + case "connections/show": { + const params = (request.params ?? {}) as ConnectionNameParams; + const connection = this.registry.connectionFor( params.name, params.requireExplicit, ); - const client = session.client; + const client = connection.client; // Recomputed live from disk (not the connect-time cache and not the // client's memory-cached storage): `show` reports the *current* // persisted auth state, so an auth/clear, auth/ema-logout, or a // web-client re-auth since connect is reflected here. - const auth = await getLiveSessionAuthInfo(session); - const result: SessionShowResult = { - name: session.name, - serverIdentity: session.serverIdentity, - connectedAt: session.connectedAt, - lastAccessedAt: session.lastAccessedAt, + const auth = await getLiveConnectionAuthInfo(connection); + const result: ConnectionShowResult = { + name: connection.name, + serverIdentity: connection.serverIdentity, + connectedAt: connection.connectedAt, + lastAccessedAt: connection.lastAccessedAt, isMru: true, serverInfo: client.getServerInfo(), protocolVersion: client.getProtocolVersion(), @@ -365,7 +365,7 @@ export class DaemonServer { }); } const client = this.registry.clientFor(params.name, params.requireExplicit); - const methodArgs = stripSessionFields(params); + const methodArgs = stripConnectionFields(params); const unwire = wireElicitationBridge(client, elicitation, requestId); let outcome; try { @@ -405,7 +405,7 @@ export class DaemonServer { }); } const client = this.registry.clientFor(params.name, params.requireExplicit); - const methodArgs = stripSessionFields(params); + const methodArgs = stripConnectionFields(params); const outcome = await runMethod(client, methodArgs); if (outcome.kind !== "stream") { throw new CliExitCodeError( @@ -443,8 +443,8 @@ export class DaemonServer { const holder = this.readLockPid(); if (holder !== undefined && isPidAlive(holder)) { throw new Error( - `Session daemon lock ${this.lockPath} is held by running pid ${holder}. ` + - `Use \`mcpi daemon/stop\`, or remove the file if that pid is not an mcpi daemon.`, + `Connection daemon lock ${this.lockPath} is held by running pid ${holder}. ` + + `Use \`mcpdo daemon/stop\`, or remove the file if that pid is not an mcpdo daemon.`, { cause: error }, ); } @@ -455,7 +455,9 @@ export class DaemonServer { } } } - throw new Error(`Could not acquire session daemon lock ${this.lockPath}`); + throw new Error( + `Could not acquire connection daemon lock ${this.lockPath}`, + ); } private readLockPid(): number | undefined { @@ -503,7 +505,7 @@ function isPidAlive(pid: number): boolean { } } -function stripSessionFields( +function stripConnectionFields( params: RpcParams, ): MethodArgs & { method: string } { const { name, requireExplicit, method, ...rest } = params; diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts similarity index 95% rename from clients/mcpi/src/daemon/stream-client.ts rename to clients/daemon-cli/src/daemon/stream-client.ts index 88f6541b5b..abf6d89c43 100644 --- a/clients/mcpi/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -14,7 +14,7 @@ import type { DaemonStreamFrame, } from "./protocol.js"; import type { DaemonClientOptions } from "./client.js"; -import { sanitizeText } from "../session/sanitize.js"; +import { sanitizeText } from "../connection/sanitize.js"; export type StreamDaemonOptions = DaemonClientOptions & { onData: (data: unknown) => void; @@ -131,7 +131,7 @@ export async function streamDaemon( fail( new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Cannot reach session daemon at ${socketPath}: ${err.message}`, + `Cannot reach connection daemon at ${socketPath}: ${err.message}`, { code: "daemon_unreachable" }, ), ); @@ -148,7 +148,7 @@ export async function streamDaemon( fail( new CliExitCodeError( EXIT_CODES.UNREACHABLE, - `Session daemon closed the connection before the stream opened`, + `Connection daemon closed the connection before the stream opened`, { code: "daemon_unreachable" }, ), ); diff --git a/clients/mcpi/src/mcp-bin.ts b/clients/daemon-cli/src/mcp-bin.ts similarity index 93% rename from clients/mcpi/src/mcp-bin.ts rename to clients/daemon-cli/src/mcp-bin.ts index fcda903255..65d5d383d4 100644 --- a/clients/mcpi/src/mcp-bin.ts +++ b/clients/daemon-cli/src/mcp-bin.ts @@ -4,7 +4,7 @@ import { realpathSync } from "fs"; import { resolve } from "path"; import { fileURLToPath } from "url"; import { handleError } from "@inspector/cli/error-handler.js"; -import { runMcp } from "./session/mcp.js"; +import { runMcp } from "./connection/mcp.js"; export { runMcp }; diff --git a/clients/mcpi/tsconfig.json b/clients/daemon-cli/tsconfig.json similarity index 98% rename from clients/mcpi/tsconfig.json rename to clients/daemon-cli/tsconfig.json index b192b9b1eb..f76c9ca96c 100644 --- a/clients/mcpi/tsconfig.json +++ b/clients/daemon-cli/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, - // Match clients/cli/tsconfig.json's module/lib *resolution* options (mcpi + // Match clients/cli/tsconfig.json's module/lib *resolution* options (mcpdo // reaches into @inspector/cli/* and @inspector/core/* the same way cli // does) so core/ and cli/ are validated the same way their own gates // validate them, rather than under base's stricter diff --git a/clients/mcpi/tsconfig.test.json b/clients/daemon-cli/tsconfig.test.json similarity index 100% rename from clients/mcpi/tsconfig.test.json rename to clients/daemon-cli/tsconfig.test.json diff --git a/clients/mcpi/tsup.config.ts b/clients/daemon-cli/tsup.config.ts similarity index 100% rename from clients/mcpi/tsup.config.ts rename to clients/daemon-cli/tsup.config.ts diff --git a/clients/mcpi/vitest.config.ts b/clients/daemon-cli/vitest.config.ts similarity index 100% rename from clients/mcpi/vitest.config.ts rename to clients/daemon-cli/vitest.config.ts diff --git a/clients/mcpi/__tests__/hoist-session.test.ts b/clients/mcpi/__tests__/hoist-session.test.ts deleted file mode 100644 index 19b9b13d1a..0000000000 --- a/clients/mcpi/__tests__/hoist-session.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { hoistAtSession } from "../src/session/dispatch.js"; - -describe("hoistAtSession", () => { - it("lifts a leading @name into sessionFromAt", () => { - const { argv, sessionFromAt } = hoistAtSession([ - "node", - "mcpi", - "@alpha", - "tools/list", - "--format", - "json", - ]); - expect(sessionFromAt).toBe("alpha"); - expect(argv).toEqual(["node", "mcpi", "tools/list", "--format", "json"]); - }); - - it("leaves argv unchanged when there is no @name", () => { - const input = ["node", "mcpi", "tools/list"]; - expect(hoistAtSession(input)).toEqual({ argv: input }); - }); -}); diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 6f92f76913..7ebe3f8dca 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -1143,9 +1143,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1163,9 +1160,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1183,9 +1177,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1203,9 +1194,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1223,9 +1211,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1243,9 +1228,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3084,9 +3066,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3108,9 +3087,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3132,9 +3108,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3156,9 +3129,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 9f7affb97d..985cb0dddf 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -1393,9 +1393,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1561,9 +1558,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1581,9 +1575,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1601,9 +1592,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1621,9 +1609,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1641,9 +1626,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1661,9 +1643,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1681,9 +1660,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1701,9 +1677,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1916,9 +1889,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1933,9 +1903,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1950,9 +1917,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1967,9 +1931,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1984,9 +1945,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2001,9 +1959,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2018,9 +1973,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2035,9 +1987,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2239,9 +2188,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2259,9 +2205,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2279,9 +2222,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2299,9 +2239,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2319,9 +2256,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2339,9 +2273,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2574,9 +2505,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2591,9 +2519,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2608,9 +2533,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2625,9 +2547,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2642,9 +2561,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2659,9 +2575,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2676,9 +2589,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2693,9 +2603,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2710,9 +2617,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2727,9 +2631,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2744,9 +2645,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2761,9 +2659,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2778,9 +2673,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5892,9 +5784,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5916,9 +5805,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5940,9 +5826,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5964,9 +5847,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index 00e01bd8f5..16597ffd4e 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -45,7 +45,7 @@ export interface RunRunnerInteractiveOAuthOptions { * so Ctrl-C rejects the flow cleanly (server stopped, classifiable error) * instead of hanging or hitting Node's default abrupt exit. Opt-in * because it is process-global state: the TUI owns Ctrl-C through Ink and - * must not have it intercepted here. CLI/mcpi callers pass `true`. + * must not have it intercepted here. CLI/mcpdo callers pass `true`. */ handleSignals?: boolean; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 8906a1bcc3..03564f88d3 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -129,7 +129,7 @@ export type StoredMCPServer = MCPServerConfig & { * Elicitation capability this client advertises to this server * (`"off" | "url" | "form" | "both"`). Inspector-specific (no analog in the * broader mcp.json ecosystem). Omitted on disk when it equals the default - * (`"both"`). Currently consumed by mcpi only. (#1783) + * (`"both"`). Currently consumed by mcpdo only. (#1783) */ elicitCapability?: ElicitCapabilityMode; /** @@ -1014,7 +1014,7 @@ export interface InspectorServerSettings { * so a bare settings node reads back without one; absence means {@link * DEFAULT_ELICIT_CAPABILITY} (`"both"`). Persisted on disk as * `elicitCapability` and omitted when it equals the default. Currently - * consumed by mcpi only (#1783) — a connect-time, sticky-per-session + * consumed by mcpdo only (#1783) — a connect-time, sticky-per-connection * choice rather than a per-call one, since a daemon-managed session can be * reused by several later callers (interactive and scripted) over its * lifetime. diff --git a/package.json b/package.json index c17689ae7a..1134f38a60 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "author": "The MCP Maintainers and Community", "type": "module", "bin": { - "mcp-inspector": "./clients/launcher/build/index.js" + "mcp-inspector": "./clients/launcher/build/index.js", + "mcpdo": "./clients/daemon-cli/build/mcp-bin.js" }, "files": [ "clients/launcher/build", @@ -26,23 +27,25 @@ "clients/web/dist", "clients/web/static", "clients/cli/build", + "clients/daemon-cli/build", "clients/tui/build", - "scripts/install-clients.mjs" + "scripts/install-clients.mjs", + "skills/mcpdo" ], "scripts": { "web": "node clients/launcher/build/index.js --web", "build:web:runner": "cd clients/web && npm run build:runner", "web:dev": "npm run build:web:runner && node clients/launcher/build/index.js --web --dev", - "build": "npm run build:web && npm run build:cli && npm run build:mcpi && npm run build:tui && npm run build:launcher", + "build": "npm run build:web && npm run build:cli && npm run build:daemon-cli && npm run build:tui && npm run build:launcher", "build:cli": "cd clients/cli && npm run build", - "build:mcpi": "cd clients/mcpi && npm run build", - "build:mcpi:dev": "cd clients/mcpi && npm run build:dev", + "build:daemon-cli": "cd clients/daemon-cli && npm run build", + "build:daemon-cli:dev": "cd clients/daemon-cli && npm run build:dev", "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", "local:gate:stages": "npm run local:validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", - "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../mcpi && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", + "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../daemon-cli && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", "local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", @@ -50,7 +53,7 @@ "verify:skills": "node scripts/verify-skills.mjs", "verify:skills:cli": "node scripts/verify-skills-cli.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:mcpi && npm run validate:tui && npm run validate:launcher", + "validate": "npm run validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:daemon-cli && npm run validate:tui && npm run validate:launcher", "validate:guards": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && npm run test:scripts", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", @@ -64,15 +67,15 @@ "format:check:scripts": "prettier --check \"scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:shared": "prettier --write \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", "format:check:shared": "prettier --check \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js", - "format": "npm run format:core && npm run format:scripts && npm run format:shared && cd clients/web && npm run format && cd ../cli && npm run format && cd ../mcpi && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", + "format": "npm run format:core && npm run format:scripts && npm run format:shared && cd clients/web && npm run format && cd ../cli && npm run format && cd ../daemon-cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", "validate:cli": "cd clients/cli && npm run validate", - "validate:mcpi": "cd clients/mcpi && npm run validate", + "validate:daemon-cli": "cd clients/daemon-cli && npm run validate", "validate:tui": "cd clients/tui && npm run validate", "validate:web": "cd clients/web && npm run validate", "validate:launcher": "cd clients/launcher && npm run validate", - "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:mcpi && npm run coverage:tui && npm run coverage:launcher", + "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:daemon-cli && npm run coverage:tui && npm run coverage:launcher", "coverage:cli": "cd clients/cli && npm run test:coverage", - "coverage:mcpi": "cd clients/mcpi && npm run test:coverage", + "coverage:daemon-cli": "cd clients/daemon-cli && npm run test:coverage", "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", diff --git a/scripts/install-clients.mjs b/scripts/install-clients.mjs index b7cf34f7b6..810aedb5e1 100644 --- a/scripts/install-clients.mjs +++ b/scripts/install-clients.mjs @@ -27,7 +27,7 @@ import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const CLIENTS = ["web", "cli", "mcpi", "tui", "launcher"]; +const CLIENTS = ["web", "cli", "daemon-cli", "tui", "launcher"]; if (process.env.INSPECTOR_SKIP_CLIENT_INSTALL) { console.log( diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 8bee6115b2..b065bb6b85 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -639,7 +639,7 @@ describe("the gate's name", () => { // that keep it honest: the gate no longer reaches a client's bare `test`, // it still reaches every non-test check `validate` reaches, and `validate` // itself (CI's inner loop) is untouched. - const clients = ["web", "cli", "mcpi", "tui", "launcher"]; + const clients = ["web", "cli", "daemon-cli", "tui", "launcher"]; const clientScripts = Object.fromEntries( clients.map((c) => [ c, @@ -693,7 +693,7 @@ describe("the gate's name", () => { for (const name of inner) if ( name !== "validate" && - !/^validate:(web|cli|mcpi|tui|launcher)$/.test(name) + !/^validate:(web|cli|daemon-cli|tui|launcher)$/.test(name) ) assert.ok(gate.has(name), `local:validate must reach ${name}`); }); diff --git a/scripts/sdk-watch.mjs b/scripts/sdk-watch.mjs index da7a1a720a..d59d82574e 100644 --- a/scripts/sdk-watch.mjs +++ b/scripts/sdk-watch.mjs @@ -529,7 +529,7 @@ export function buildIssueBody(state) { "### Upgrade checklist", "", ...manifestChecklist(rows, target), - "- [ ] Re-check the bundler `external` lists (`clients/{cli,mcpi,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`) if the release adds or renames an entry point; `npm run verify:bundle-externals` enforces this against the built output.", + "- [ ] Re-check the bundler `external` lists (`clients/{cli,daemon-cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`) if the release adds or renames an entry point; `npm run verify:bundle-externals` enforces this against the built output.", "- [ ] `npm run format`, then `npm run local:gate`.", "", "An automated review of what actually changed upstream — and which parts of this app it touches — is posted as a comment below.", diff --git a/scripts/verify-bundle-externals.mjs b/scripts/verify-bundle-externals.mjs index 60d0e8ed19..e6f3e066bc 100644 --- a/scripts/verify-bundle-externals.mjs +++ b/scripts/verify-bundle-externals.mjs @@ -37,7 +37,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); * the build directory to inspect. `entry` names the file whose presence * proves a build actually ran; it defaults to `index.js` (what web/cli/tui * each name their single tsup entry) and is overridden only when a client's - * tsup config uses a different entry name, like mcpi's multi-entry `mcp-bin`. + * tsup config uses a different entry name, like daemon-cli's multi-entry `mcp-bin`. * `clients/launcher` is plain `tsc` — it emits no bundle and inlines nothing — * so it has nothing to check. */ @@ -58,9 +58,9 @@ export const BUNDLED_CLIENTS = [ build: "clients/tui/build", }, { - name: "mcpi", - config: "clients/mcpi/tsup.config.ts", - build: "clients/mcpi/build", + name: "daemon-cli", + config: "clients/daemon-cli/tsup.config.ts", + build: "clients/daemon-cli/build", entry: "mcp-bin.js", }, ]; diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index d1a73fb75c..55fca8e500 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -51,7 +51,7 @@ const MANIFESTS = [ ".", "clients/web", "clients/cli", - "clients/mcpi", + "clients/daemon-cli", "clients/tui", "clients/launcher", ]; diff --git a/scripts/verify-test-timeouts.mjs b/scripts/verify-test-timeouts.mjs index 121a40682d..774542d67e 100644 --- a/scripts/verify-test-timeouts.mjs +++ b/scripts/verify-test-timeouts.mjs @@ -93,7 +93,7 @@ export const EXPECTED_PROJECTS = Object.freeze({ cli: EXPECTED_TIMEOUTS, tui: EXPECTED_TIMEOUTS, launcher: EXPECTED_TIMEOUTS, - mcpi: EXPECTED_TIMEOUTS, + "daemon-cli": EXPECTED_TIMEOUTS, }); /** @@ -109,7 +109,7 @@ export const CONFIG_ROOTS = Object.freeze([ { root: "clients/cli", projects: ["cli"] }, { root: "clients/tui", projects: ["tui"] }, { root: "clients/launcher", projects: ["launcher"] }, - { root: "clients/mcpi", projects: ["mcpi"] }, + { root: "clients/daemon-cli", projects: ["daemon-cli"] }, ]); /** diff --git a/scripts/verify-test-timeouts.test.mjs b/scripts/verify-test-timeouts.test.mjs index d4c5bdec3e..4d798a8214 100644 --- a/scripts/verify-test-timeouts.test.mjs +++ b/scripts/verify-test-timeouts.test.mjs @@ -123,7 +123,7 @@ test("a Vitest config this guard does not check is an error", () => { "clients/cli", "clients/tui", "clients/launcher", - "clients/mcpi", + "clients/daemon-cli", "clients/desktop", ]); assert.equal(failures.length, 1); diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md new file mode 100644 index 0000000000..28c5babe03 --- /dev/null +++ b/skills/mcpdo/SKILL.md @@ -0,0 +1,54 @@ +--- +name: mcpdo +description: Use the mcpdo CLI to connect to Model Context Protocol (MCP) servers and run tools, read resources, list prompts, and more from the command line or from an agent's shell. Use this skill whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. +--- + +# mcpdo — MCP Inspector connection CLI + +Connect to an MCP server once, then run many commands against that named +connection. + +```bash +mcpdo connect ./path/to/server.json # config-file entry +mcpdo connect https://example.com/mcp # ad-hoc HTTP/SSE target +mcpdo connect node server.js # ad-hoc stdio target + +mcpdo tools/list +mcpdo tools/call arg:=value +mcpdo resources/list +mcpdo resources/read +mcpdo prompts/list + +mcpdo @my-connection tools/list # target a specific connection +mcpdo --connection my-connection tools/list + +mcpdo disconnect +``` + +Run `mcpdo help` or `mcpdo --help` for the full, authoritative list of +commands and flags. + +## Conventions + +- `--format json` outputs JSON; the default, `--format text`, is + human-readable. +- `mcpdo connections/list` shows open connections; `@name` (prefix on any command) + or `--connection ` (shorthand `--conn`) selects one explicitly when the most-recently-used + connection isn't the right one. +- A connected connection persists across separate `mcpdo` invocations — no need + to reconnect before each command. `mcpdo disconnect` ends one connection; + `mcpdo daemon stop` resets everything. +- `mcpdo connect --config path/to/mcp.json` connects a + pre-declared catalog entry (may include auth, headers, protocol-era + overrides); `mcpdo connect ` connects an ad-hoc target with + defaults. +- Auth is handled automatically at connect time and stored for reuse (`mcpdo + auth/list` / `mcpdo auth/clear`); nothing extra is needed for authenticated + HTTP servers beyond `connect` and completing the browser flow if prompted. +- If a server asks a question mid-call (elicitation), mcpdo prompts + interactively by default — including over a plain non-TTY stdin, so an + agent can relay the question and answer it. Only `--format json` (whose + stdout must stay a single machine-readable payload) auto-declines instead + of prompting. Pass `--elicit off` on + `connect` if you want a well-behaved server to fall back to its own + defaults instead. diff --git a/skills/mcpi/SKILL.md b/skills/mcpi/SKILL.md deleted file mode 100644 index f553b5bf52..0000000000 --- a/skills/mcpi/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: mcpi -description: Use the mcpi CLI to connect to Model Context Protocol (MCP) servers and run tools, read resources, list prompts, and more from the command line or from an agent's shell. Use this skill whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. ---- - -# mcpi — MCP Inspector session CLI - -Connect to an MCP server once, then run many commands against that named -session. - -```bash -mcpi connect ./path/to/server.json # config-file entry -mcpi connect https://example.com/mcp # ad-hoc HTTP/SSE target -mcpi connect node server.js # ad-hoc stdio target - -mcpi tools/list -mcpi tools/call arg:=value -mcpi resources/list -mcpi resources/read -mcpi prompts/list - -mcpi @my-session tools/list # target a specific session -mcpi --session my-session tools/list - -mcpi disconnect -``` - -Run `mcpi help` or `mcpi --help` for the full, authoritative list of -commands and flags. - -## Conventions - -- `--format json` outputs JSON; the default, `--format text`, is - human-readable. -- `mcpi sessions/list` shows open sessions; `@name` (prefix on any command) - or `--session ` selects one explicitly when the most-recently-used - session isn't the right one. -- A connected session persists across separate `mcpi` invocations — no need - to reconnect before each command. `mcpi disconnect` ends one session; - `mcpi daemon stop` resets everything. -- `mcpi connect --config path/to/mcp.json` connects a - pre-declared catalog entry (may include auth, headers, protocol-era - overrides); `mcpi connect ` connects an ad-hoc target with - defaults. -- Auth is handled automatically at connect time and stored for reuse (`mcpi - auth/list` / `mcpi auth/clear`); nothing extra is needed for authenticated - HTTP servers beyond `connect` and completing the browser flow if prompted. -- If a server asks a question mid-call (elicitation), mcpi prompts - interactively by default — including over a plain non-TTY stdin, so an - agent can relay the question and answer it. Only `--format json` (whose - stdout must stay a single machine-readable payload) auto-declines instead - of prompting. Pass `--elicit off` on - `connect` if you want a well-behaved server to fall back to its own - defaults instead. diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 54f8374435..03bb15d5eb 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -20,7 +20,7 @@ This document describes how those clients are built, wired, and tested today, an ## Non-goals -- **CLI v2 sessions** (connect once, many subcommands) — as-built in [v2_cli_v2.md](v2_cli_v2.md) (`mcpi` bin session-first; `mcp-inspector --cli` stays one-shot); tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). +- **CLI v2 connections** (connect once, many subcommands) — as-built in [v2_cli_v2.md](v2_cli_v2.md) (`mcpdo` bin connection-first; `mcp-inspector --cli` stays one-shot); tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). - **npm workspaces** — v2 uses a fat root package plus per-client `package.json` for dev dependencies; the launcher resolves sibling `build/` outputs via relative paths, not workspace hoisting. - _Why not workspaces:_ `core/` is consumed by **bundling** — a Vite alias for the browser, tsup inlining for the Node clients — not by symlinked package resolution, so workspaces' main benefit (cross-package linking) does not apply. Each client also pins `react` / `@modelcontextprotocol/sdk` to its own `node_modules` (see `vitest.shared.mts`) to avoid dual-package-instance hazards, which hoisting works against. And the published `@modelcontextprotocol/inspector` is a single flat fat package that workspaces would complicate rather than simplify. - _Cost (from-source dev only):_ there is no hoisting, so each client keeps its own `node_modules`. A root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into every client, so a single `npm install` at the repo root populates them all — re-run it after a pull that changes a client's dependencies. The cascade no-ops outside a source checkout (it exits early when running from `node_modules`, and the published tarball ships only each client's `build/`, no client `package.json`), so end users of the published package are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip the cascade (e.g. CI that installs each client itself). @@ -35,7 +35,7 @@ This document describes how those clients are built, wired, and tested today, an | ---------- | ------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- | | Launcher | `clients/launcher/` | `tsc` → `build/index.js` | Root `mcp-inspector` → `clients/launcher/build/index.js` | | CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only; one-shot) | -| mcpi | `clients/mcpi/` | `tsup` → `build/mcp-bin.js` + `build/daemon.js` | `mcpi` (experimental; not shipped in inspector package) | +| daemon-cli | `clients/daemon-cli/` | `tsup` → `build/mcp-bin.js` + `build/daemon.js` | `mcpdo` (experimental; ships in the inspector package) | | TUI | `clients/tui/` | `tsup` → `build/index.js` | `mcp-inspector-tui` (client package only) | | Web runner | `clients/web/server/run-web.ts` | `tsup` (`build:runner`) → `clients/web/build/index.js` | `mcp-inspector-web` (client package only) | @@ -95,7 +95,7 @@ All three clients import from `@inspector/core/...` (mapped to `../../core/` sou ## CLI -**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Session-oriented CLI v2 (`mcpi`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). +**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Connection-oriented CLI v2 (`mcpdo`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). **Entry:** `clients/cli/src/index.ts` exports `runCli(argv)`; `src/cli.ts` owns Commander parsing and `InspectorClient` orchestration. diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 2be9f14e19..49cc48c088 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -1,42 +1,42 @@ -# Inspector CLI v2 (session-oriented) +# Inspector CLI v2 (connection-oriented) ### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) -Documentation of the **experimental** session-oriented Inspector CLI (`mcpi`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). `mcpi` is a separate client under `clients/mcpi/` and is **not** shipped in `@modelcontextprotocol/inspector`. +Documentation of the **experimental** connection-oriented Inspector CLI (`mcpdo`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). `mcpdo` is a separate client under `clients/daemon-cli/`, shipped as the `mcpdo` bin in `@modelcontextprotocol/inspector` (experimental). -**Related:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/mcpi/README.md`](../clients/mcpi/README.md), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). +**Related:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/daemon-cli/README.md`](../clients/daemon-cli/README.md), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). --- ## Overview -| | **One-shot** | **Session** | +| | **One-shot** | **Connection** | | --- | --- | --- | -| Entrypoint | `mcp-inspector --cli` | `mcpi` | +| Entrypoint | `mcp-inspector --cli` | `mcpdo` | | Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands → disconnect | -| Process | In-process only | Short-lived front-end + implicit session daemon (IPC) | -| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/mcpi` (experimental separate client; not shipped in the inspector package) | +| Process | In-process only | Short-lived front-end + implicit connection daemon (IPC) | +| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/daemon-cli` (experimental; ships the `mcpdo` bin with `@modelcontextprotocol/inspector`) | -Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (mcpi reaches in via a temporary `@inspector/cli` build alias). One-shot never starts the daemon. `mcpi` does not accept `--method`. +Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (mcpdo reaches in via a temporary `@inspector/cli` build alias). One-shot never starts the daemon. `mcpdo` does not accept `--method`. ```bash -mcpi servers/list --config mcp.json -mcpi servers/show my-server --config mcp.json -mcpi connect myserver --config mcp.json -mcpi tools/list -mcpi tools/call search query:=hello -mcpi @other resources/list -mcpi disconnect +mcpdo servers/list --config mcp.json +mcpdo servers/show my-server --config mcp.json +mcpdo connect myserver --config mcp.json +mcpdo tools/list +mcpdo tools/call search query:=hello +mcpdo @other resources/list +mcpdo disconnect ``` Optional private daemon for one shell (`ssh-agent` style): ```bash -eval "$(mcpi private)" -mcpi connect myserver --config mcp.json -mcpi tools/list +eval "$(mcpdo private)" +mcpdo connect myserver --config mcp.json +mcpdo tools/list ``` --- @@ -48,16 +48,16 @@ mcpi tools/list | Piece | Location | | --- | --- | | One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | -| Session front-end | `clients/mcpi/src/session/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`) + `mcp-bin.ts` | -| Daemon | `clients/mcpi/src/daemon/` → `clients/mcpi/build/daemon.js` | +| Connection front-end | `clients/daemon-cli/src/connection/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`) + `mcp-bin.ts` | +| Daemon | `clients/daemon-cli/src/daemon/` → `clients/daemon-cli/build/daemon.js` | | Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | ``` -mcp-inspector --cli … mcpi … +mcp-inspector --cli … mcpdo … │ │ ▼ ▼ - clients/cli clients/mcpi - cli.ts session/mcp.ts + clients/cli clients/daemon-cli + cli.ts connection/mcp.ts │ │ NDJSON IPC │ daemon (build/daemon.js) └──────────┬─────────────┘ @@ -67,7 +67,7 @@ mcp-inspector --cli … mcpi … ### One-shot (`mcp-inspector --cli`) -Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the session daemon. +Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the connection daemon. | `--method` | Notes | | --- | --- | @@ -80,23 +80,23 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) **Auth:** Interactive OAuth + mid-session recovery in-process (`cliOAuth.ts`); `--stored-auth-only`, `--use-stored-auth`, handoff flags. See [clients/cli/README.md](../clients/cli/README.md). -### Session CLI (`mcpi`) +### Connection CLI (`mcpdo`) #### Commands | Category | Commands | | --- | --- | | Catalog | `servers/list`, `servers/show ` | -| Session | `connect` (`--relogin`), `disconnect`, `sessions/list`, `sessions/use` | +| Connection | `connect` (`--relogin`), `disconnect`, `connections/list`, `connections/use` | | Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | | Daemon | `private`, `daemon status`, `daemon stop` | | MCP | `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` | -**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. +**Globals (before subcommand):** `--format text|json`, `--plain`, `--connection `, `--catalog` / `--config`, `--stored-auth-only`. -**Session select:** leading `@name` and/or `--session `. Tool args: `key:=value`, inline JSON, or `--tool-arg` / `--tool-args-json`. +**Connection select:** leading `@name` and/or `--connection `. Tool args: `key:=value`, inline JSON, or `--tool-arg` / `--tool-args-json`. -**Connect forms:** catalog entry / `--server` / ad-hoc URL or command; optional `@name` to override session name (default = entry id). +**Connect forms:** catalog entry / `--server` / ad-hoc URL or command; optional `@name` to override connection name (default = entry id). #### Output @@ -106,18 +106,18 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) | `--format json` | Pretty-printed payload (**no** `{ result }` envelope; never ANSI). | | Streams | Long-lived until Ctrl-C; human lines or pretty JSON events per `--format`. | -#### Default session (MRU) +#### Default connection (MRU) -- Omit `@name` / `--session` → MRU (TTY). -- Explicit `@name` / `--session` always wins. -- Non-TTY: require explicit session unless `MCP_ALLOW_DEFAULT_SESSION=1`. -- `sessions/list`, `sessions/use `; `daemon status` / `sessions/list` do **not** auto-spawn the daemon. +- Omit `@name` / `--connection` → MRU (TTY). +- Explicit `@name` / `--connection` always wins. +- Non-TTY: require explicit connection unless `MCP_ALLOW_DEFAULT_CONNECTION=1`. +- `connections/list`, `connections/use `; `daemon status` / `connections/list` do **not** auto-spawn the daemon. #### Daemon -**IPC ops:** `ping`, `connect`, `disconnect`, `sessions/list`, `sessions/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. +**IPC ops:** `ping`, `connect`, `disconnect`, `connections/list`, `connections/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. -- One `InspectorClient` per named session; auto-spawn on first need; idle exit ~60s after last disconnect **or** after a session-less spawn with no successful connect; `daemon stop` tears down immediately. +- One `InspectorClient` per named connection; auto-spawn on first need; idle exit ~60s after last disconnect **or** after a connection-less spawn with no successful connect; `daemon stop` tears down immediately. - Socket/lock mode `0600` (best-effort). Config (incl. secrets) over IPC after listen — not on daemon argv. - Errors that are not already `CliExitCodeError` go through `classifyError` (exit-code parity with one-shot). @@ -126,36 +126,36 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) | Shared default | `~/.mcp-inspector/daemon.sock` (+ `daemon.lock`, `daemon.token`, `daemon.log`) | | `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | | `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | -| Private | `$TMPDIR/mcpi-//` (0700, short id — `sun_path` caps socket paths at 104 bytes on macOS) from `mcpi private` | +| Private | `$TMPDIR/mcp-conn-//` (0700, short id — `sun_path` caps socket paths at 104 bytes on macOS) from `mcpdo private` | | Mode | Trust | | --- | --- | -| **Shared (default)** | Auto-generated token, published to `daemon.token` (0600) in the daemon dir (0700). Same-UID peer that can read the dir can drive sessions (intentional cross-terminal share); there is no unauthenticated request path. | -| **Private** | `eval "$(mcpi private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | +| **Shared (default)** | Auto-generated token, published to `daemon.token` (0600) in the daemon dir (0700). Same-UID peer that can read the dir can drive connections (intentional cross-terminal share); there is no unauthenticated request path. | +| **Private** | `eval "$(mcpdo private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | -#### Auth (session) +#### Auth (connection) - Same `oauth.json` store as other Inspector clients. - **Connect-time:** daemon connect → on `auth_required`, front-end `authorizeInFrontend()` (unless `--stored-auth-only`) → retry connect. - **`--relogin`:** clear any stored OAuth for the server URL before connect; interactive login still runs only if auth is required afterward. No-op for stdio / targets with no URL-keyed store entry (do not reject — same semantics, nothing to clear). - **Mid-session** step-up during `rpc` / `stream`: **not implemented** (see To-do). Use one-shot, or disconnect / re-auth / reconnect. -- Session `connect` does not expose one-shot OAuth flags (`--client-id`, `--callback-url`, …); env / defaults / `MCP_OAUTH_CALLBACK_URL` only. +- Connection `connect` does not expose one-shot OAuth flags (`--client-id`, `--callback-url`, …); env / defaults / `MCP_OAUTH_CALLBACK_URL` only. -#### One-shot ↔ session mapping +#### One-shot ↔ connection mapping -| One-shot | Session | +| One-shot | Connection | | --- | --- | -| `… --catalog mcp.json --server s --method tools/list` | `mcpi connect --catalog mcp.json s` then `mcpi tools/list` | -| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpi tools/call X key:=val` / `'{"…"}'` | -| `… --method servers/list` | `mcpi servers/list` | -| `… --method servers/show --server ` | `mcpi servers/show ` | +| `… --catalog mcp.json --server s --method tools/list` | `mcpdo connect --catalog mcp.json s` then `mcpdo tools/list` | +| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpdo tools/call X key:=val` / `'{"…"}'` | +| `… --method servers/list` | `mcpdo servers/list` | +| `… --method servers/show --server ` | `mcpdo servers/show ` | ### Testing | Client | Runner | Coverage | | --- | --- | --- | | One-shot (`clients/cli`) | In-process `runCli()`; thin binary e2e | Per-file ≥90 on `clients/cli/src`. Exclusion: `src/index.ts`. | -| Session (`clients/mcpi`) | In-process `runMcp()`; daemon IPC + stream + private-token tests | Per-file ≥90 on `clients/mcpi/src`. Exclusions: `mcp-bin.ts`, `daemon/run.ts`, `ipc-glue.ts`, `stream-client.ts`. | +| Connection CLI (`clients/daemon-cli`) | In-process `runMcp()`; daemon IPC + stream + private-token tests | Per-file ≥90 on `clients/daemon-cli/src`. Exclusions: `mcp-bin.ts`, `daemon/run.ts` (bootstraps only). | Both are wired into root `validate` / `coverage`. @@ -165,20 +165,19 @@ Both are wired into root `validate` / `coverage`. | Item | Notes | | --- | --- | -| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpi` during `rpc`/`stream`. Connect-time only today. | +| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpdo` during `rpc`/`stream`. Connect-time only today. | | **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | | **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | -| **Per-session RPC mutex** | Parallel `mcpi` processes against one session can interleave on one `InspectorClient`. | +| **Per-connection RPC mutex** | Parallel `mcpdo` processes against one connection can interleave on one `InspectorClient`. | | **`streamDaemon` post-open errors** | Socket errors after the initial ok frame are treated as soft end. | -| **Coverage gate for `ipc-glue` / `stream-client`** | Behavioral tests exist; files excluded until the race matrix is stably ≥90. | | **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | -| **Split `registerRpcCommands`** | Large Commander switch in `session/mcp.ts`. | -| **`mcpi daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | -| **Launcher help polish** | Make `mcpi` vs `--cli` unmistakable in launcher `--help` / docs. | -| **Session `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; session authorize uses defaults / env only. | +| **Split `registerRpcCommands`** | Large Commander switch in `connection/mcp.ts`. | +| **`mcpdo daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | +| **Launcher help polish** | Make `mcpdo` vs `--cli` unmistakable in launcher `--help` / docs. | +| **Connection `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; connection authorize uses defaults / env only. | | **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | -| **Stream fan-out / `mcpi attach`** | One consumer per stream invocation today. | -| **Sampling CLI** | Still TUI/web. mcpi handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | -| **Ephemeral no-`connect` shortcuts on `mcpi`** | Out of scope (keep two mental models). | -| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | +| **Stream fan-out / `mcpdo attach`** | One consumer per stream invocation today. | +| **Sampling CLI** | Still TUI/web. mcpdo handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | +| **Ephemeral no-`connect` shortcuts on `mcpdo`** | Out of scope (keep two mental models). | +| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_CONNECTION=1`. | | **Human `--full` schema dumps** | Optional formatter polish. | From b302636165788de1c30b7ac4d9f6117efc3bd5c4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 12:31:19 -0700 Subject: [PATCH 10/69] fix(daemon-cli): address Copilot review round 1 on #1783 - ensure: a losing concurrent starter re-reads the winner's published daemon.token instead of polling its own dead token into a bogus daemon_start_timeout (explicit/private tokens still fail loud); test - ipc-glue: enforce the 1 MiB line cap per newline-delimited segment so a terminated oversized line can't reset the counter past the check, and ignore lines after rejection; unit + e2e regression tests - elicitation: parse the form schema raw and sanitize server-controlled strings at render points only, so responses carry the server's own keys/values - form-prompt: reject non-finite numbers ("Infinity" is not a valid JSON number) - resolve-command: honor an empty PATH entry as the current directory (POSIX) and return absolute paths for relative entries - lint: add the type-aware no-floating-promises pass and --max-warnings 0, matching clients/cli - docs: AGENTS.md external-lists brace path mcpdo -> daemon-cli; SKILL.md connect example uses --config; spec no longer advertises unregistered `initialize` Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- .../__tests__/daemon-ipc-glue.test.ts | 22 +++++++ .../__tests__/daemon-private.test.ts | 62 +++++++++++++++++++ clients/daemon-cli/eslint.config.js | 17 +++++ clients/daemon-cli/package.json | 2 +- .../src/connection/elicitation-prompt.ts | 8 ++- .../daemon-cli/src/connection/form-prompt.ts | 20 +++--- .../src/connection/resolve-command.ts | 6 +- clients/daemon-cli/src/daemon/ensure.ts | 33 ++++++++-- clients/daemon-cli/src/daemon/ipc-glue.ts | 33 +++++++--- skills/mcpdo/SKILL.md | 2 +- specification/v2_cli_v2.md | 2 +- 12 files changed, 181 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aff6374740..01d5efc0c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ The reasoning behind each of these, and what breaks when it is ignored, is the - **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale. - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way). - ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301). -- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,mcpdo,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. +- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,daemon-cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**. - **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`). - **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both. - **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading. diff --git a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts index ec9fdad51f..769a49b097 100644 --- a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts +++ b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts @@ -9,6 +9,7 @@ import { Duplex } from "node:stream"; import type * as net from "node:net"; import { acceptDaemonConnection, + MAX_REQUEST_LINE_BYTES, type ElicitationChannel, } from "../src/daemon/ipc-glue.js"; import type { @@ -231,3 +232,24 @@ describe("acceptDaemonConnection guards", () => { expect(socket.all.split('"stream":"data"').length - 1).toBe(1); }); }); + +describe("request line cap", () => { + it("never hands a terminated oversized line to the handler", async () => { + // Deterministic cross-chunk variant of the e2e cap tests: a valid JSON + // request padded past the cap, split so the chunk that crosses the limit + // also carries the terminating newline. Both the byte accounting and the + // post-reject line guard must hold, or the handler sees the request. + let handled = 0; + const socket = accept(async (request) => { + handled += 1; + return { response: { id: request.id, ok: true, result: {} } }; + }); + const padded = + REQUEST + " ".repeat(MAX_REQUEST_LINE_BYTES + 1024 - REQUEST.length); + socket.push(padded.slice(0, 600 * 1024)); + socket.push(padded.slice(600 * 1024) + "\n"); + await until(() => socket.destroyed); + await new Promise((resolve) => setImmediate(resolve)); + expect(handled).toBe(0); + }); +}); diff --git a/clients/daemon-cli/__tests__/daemon-private.test.ts b/clients/daemon-cli/__tests__/daemon-private.test.ts index d46ec66849..01bb62d2c2 100644 --- a/clients/daemon-cli/__tests__/daemon-private.test.ts +++ b/clients/daemon-cli/__tests__/daemon-private.test.ts @@ -194,6 +194,68 @@ describe("private daemon end-to-end", () => { expect(closed).toBe(true); }); + it("rejects an oversized line even when its terminator arrives with it", async () => { + // Regression: the old cap only counted bytes after a chunk's last + // newline, so an oversized line whose terminating "\n" arrived in the + // crossing chunk reset the counter and reached readline. + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-cap2-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const net = await import("node:net"); + const closed = await new Promise((resolve) => { + const socket = net.connect(server!.socketPath, () => { + socket.write(Buffer.alloc(600 * 1024, 0x61)); + socket.write( + Buffer.concat([Buffer.alloc(600 * 1024, 0x61), Buffer.from("\n")]), + ); + }); + const done = () => resolve(true); + socket.once("close", done); + socket.once("error", done); + setTimeout(() => { + socket.destroy(); + resolve(false); + }, 5000).unref(); + }); + expect(closed).toBe(true); + }); + + it("adopts the winner's published token when a concurrent starter wins the lock", async () => { + // Two concurrent first invocations each generate a token and spawn; the + // pid lock lets one daemon survive. The loser must finish against the + // winner's daemon by re-reading its published daemon.token, not poll + // with its own dead token until daemon_start_timeout. + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-race-")); + const prevTok = process.env[DAEMON_TOKEN_ENV]; + delete process.env[DAEMON_TOKEN_ENV]; + try { + // Our child lost the O_EXCL pid lock: it exits without binding. + const stub = path.join(dir, "losing-daemon.js"); + fs.writeFileSync(stub, "process.exit(0);\n"); + const ensured = ensureDaemon({ dir, daemonScript: stub }); + // The concurrent winner, holding a different (published) token. + server = new DaemonServer({ + dir, + idleMs: 0, + requiredToken: "winner-token", + }); + await server.start(); + + const { socketPath, spawned } = await ensured; + expect(spawned).toBe(true); + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath, timeoutMs: 2000, token: "winner-token" }, + ); + expect(pong.pong).toBe(true); + } finally { + if (prevTok === undefined) delete process.env[DAEMON_TOKEN_ENV]; + else process.env[DAEMON_TOKEN_ENV] = prevTok; + } + }); + it("connection front-end rethrows non-unreachable daemon errors", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-")); const token = "good-token"; diff --git a/clients/daemon-cli/eslint.config.js b/clients/daemon-cli/eslint.config.js index 1c43ee8fdb..ff42ec3224 100644 --- a/clients/daemon-cli/eslint.config.js +++ b/clients/daemon-cli/eslint.config.js @@ -14,4 +14,21 @@ export default defineConfig([ globals: globals.node, }, }, + { + // Type-aware pass for `no-floating-promises` (#1959), mirroring + // clients/cli: the rule needs type information, and the parser needs a + // project that literally contains the linted file — so both of this + // client's tsconfig projects are listed, exactly as `npm run typecheck` + // runs them (`src` is in the first, `__tests__` only in the second). + files: ["**/*.ts"], + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.test.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, ]); diff --git a/clients/daemon-cli/package.json b/clients/daemon-cli/package.json index 1d071c63cc..a91fc9e2a1 100644 --- a/clients/daemon-cli/package.json +++ b/clients/daemon-cli/package.json @@ -23,7 +23,7 @@ "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", "test-servers:build": "tsc -p ../../test-servers --noCheck", "pretest": "npm run test-servers:build && npm run build", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, diff --git a/clients/daemon-cli/src/connection/elicitation-prompt.ts b/clients/daemon-cli/src/connection/elicitation-prompt.ts index 7f08811fa2..d8379c10ba 100644 --- a/clients/daemon-cli/src/connection/elicitation-prompt.ts +++ b/clients/daemon-cli/src/connection/elicitation-prompt.ts @@ -21,7 +21,7 @@ import type { } from "../daemon/protocol.js"; import { parseFormSchema } from "./form-schema.js"; import { promptForm, watchForClose } from "./form-prompt.js"; -import { sanitizeDeep, sanitizeText } from "./sanitize.js"; +import { sanitizeText } from "./sanitize.js"; export type PromptElicitationOpts = { /** @@ -77,7 +77,11 @@ export async function promptElicitation( const url = frame.url === undefined ? undefined : sanitizeText(frame.url); if (frame.mode === "form") { - const fields = parseFormSchema(sanitizeDeep(frame.requestedSchema)); + // The schema is parsed raw: sanitizing it wholesale would mutate protocol + // data (property names, enum values, defaults), so the accepted response + // could carry keys/values the server never defined. Server-controlled + // strings are instead sanitized at each render point in form-prompt.ts. + const fields = parseFormSchema(frame.requestedSchema); if (!fields) { // Schema outside the spec's restricted primitive-field shape — // shouldn't happen from a well-behaved server; decline clearly rather diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index 502151035b..dd37586124 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -9,6 +9,7 @@ import type { Interface as ReadlineInterface } from "node:readline/promises"; import type { Style } from "@inspector/cli/style.js"; import type { FormField } from "./form-schema.js"; +import { sanitizeText } from "./sanitize.js"; export type FormOutcome = | { action: "accept"; content: Record } @@ -49,11 +50,14 @@ function formatDefault(field: FormField): string | undefined { } function describeField(field: FormField, style: Style): string { + // Titles, descriptions and defaults are server-controlled: sanitize at the + // render point only, so the raw values still travel in the response. const req = field.required ? style.yellow(" (required)") : ""; - const desc = field.description ? ` — ${field.description}` : ""; + const desc = field.description ? ` — ${sanitizeText(field.description)}` : ""; const def = formatDefault(field); - const defHint = def !== undefined ? style.dim(` [default: ${def}]`) : ""; - return `${style.bold(field.title)}${req}${desc}${defHint}`; + const defHint = + def !== undefined ? style.dim(` [default: ${sanitizeText(def)}]`) : ""; + return `${style.bold(sanitizeText(field.title))}${req}${desc}${defHint}`; } /** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */ @@ -82,7 +86,7 @@ async function promptField( if (field.kind === "enum" || field.kind === "multiselect") { const lines = field.choices.map( - (choice, i) => ` ${i + 1}. ${choice.label}`, + (choice, i) => ` ${i + 1}. ${sanitizeText(choice.label)}`, ); const multi = field.kind === "multiselect"; const prompt = multi @@ -147,7 +151,9 @@ async function promptField( } const n = Number(raw); if ( - Number.isNaN(n) || + // isFinite (not isNaN): "Infinity" is not a valid JSON number and + // would serialize as null in the response frame. + !Number.isFinite(n) || (field.integer && !Number.isInteger(n)) || (field.minimum !== undefined && n < field.minimum) || (field.maximum !== undefined && n > field.maximum) @@ -171,7 +177,7 @@ async function promptField( const raw = await ask( rl, closed, - `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, + `${describeField(field, style)}\n ${def !== undefined ? `[${sanitizeText(def)}]` : ""}: `, ); const value = raw === "" && def !== undefined ? def : raw; if (value === "" && field.required) { @@ -218,7 +224,7 @@ export async function promptForm( for (const field of fields) { const v = values.get(field.name); process.stderr.write( - ` ${field.title}: ${v === undefined ? style.dim("(none)") : String(v)}\n`, + ` ${sanitizeText(field.title)}: ${v === undefined ? style.dim("(none)") : sanitizeText(String(v))}\n`, ); } const answer = ( diff --git a/clients/daemon-cli/src/connection/resolve-command.ts b/clients/daemon-cli/src/connection/resolve-command.ts index b0cb0b7cbb..ee8d633d3b 100644 --- a/clients/daemon-cli/src/connection/resolve-command.ts +++ b/clients/daemon-cli/src/connection/resolve-command.ts @@ -31,9 +31,11 @@ export function resolveCommandPath( ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + // POSIX: an empty PATH entry means the current directory. Resolve it (and + // any relative entry) against the caller's cwd so the daemon always + // receives an absolute path. for (const ext of extensions) { - const candidate = path.join(dir, command + ext); + const candidate = path.resolve(dir === "" ? "." : dir, command + ext); try { const stat = fs.statSync(candidate); if (!stat.isFile()) continue; diff --git a/clients/daemon-cli/src/daemon/ensure.ts b/clients/daemon-cli/src/daemon/ensure.ts index 76de22a4cb..1b4036cfd5 100644 --- a/clients/daemon-cli/src/daemon/ensure.ts +++ b/clients/daemon-cli/src/daemon/ensure.ts @@ -75,15 +75,34 @@ async function isDaemonReachable(socketPath: string): Promise { async function waitForDaemon( socketPath: string, - token: string | undefined, + token: string, logPath: string, - timeoutMs: number = READY_TIMEOUT_MS, + opts?: { + timeoutMs?: number; + /** + * Set only when `token` was self-generated (shared mode). Two concurrent + * first invocations each generate a token and spawn; the pid lock lets + * one daemon survive, and it may not be ours. Re-reading the winner's + * published `daemon.token` between polls lets the losing caller finish + * against the surviving daemon instead of timing out on auth failures. + * Explicitly supplied / private-mode tokens never fall back — a mismatch + * there must stay a loud failure. + */ + rereadTokenDir?: string; + }, ): Promise { - const deadline = Date.now() + timeoutMs; + const deadline = Date.now() + (opts?.timeoutMs ?? READY_TIMEOUT_MS); while (Date.now() < deadline) { if (await isDaemonReachable(socketPath)) { + const effectiveToken = opts?.rereadTokenDir + ? (readDaemonTokenFile(opts.rereadTokenDir) ?? token) + : token; try { - await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + await callDaemon( + "ping", + {}, + { socketPath, timeoutMs: 2000, token: effectiveToken }, + ); return; } catch { // connected but not ready yet @@ -151,6 +170,7 @@ export async function ensureDaemon(options?: { // Every daemon requires a token; generate one for the child when the // caller/environment didn't supply one. The daemon republishes it to // daemon.token (0600) so unrelated clients can still connect. + const tokenWasGenerated = token === undefined; token ??= generateDaemonToken(); const script = options?.daemonScript ?? resolveDaemonScriptPath(); const childEnv: NodeJS.ProcessEnv = { @@ -184,6 +204,9 @@ export async function ensureDaemon(options?: { fs.closeSync(stderrTarget); } - await waitForDaemon(socketPath, token, logPath, options?.readyTimeoutMs); + await waitForDaemon(socketPath, token, logPath, { + timeoutMs: options?.readyTimeoutMs, + rereadTokenDir: tokenWasGenerated ? dir : undefined, + }); return { socketPath, spawned: true }; } diff --git a/clients/daemon-cli/src/daemon/ipc-glue.ts b/clients/daemon-cli/src/daemon/ipc-glue.ts index e793ba5335..0831be1ae9 100644 --- a/clients/daemon-cli/src/daemon/ipc-glue.ts +++ b/clients/daemon-cli/src/daemon/ipc-glue.ts @@ -111,16 +111,29 @@ export function acceptDaemonConnection( handle: HandleRequest, ): void { // Enforce the line cap below readline: track bytes since the last newline - // and drop the connection once a single line exceeds the limit. + // and drop the connection once a single line exceeds the limit. Every + // newline-delimited segment is checked at its full accumulated size before + // the counter resets — checking only the tail of a chunk would let an + // oversized line slip through whenever the chunk that crosses the limit + // also contains the terminating newline. let bytesSinceNewline = 0; + let rejected = false; socket.on("data", (chunk: Buffer) => { - const idx = chunk.lastIndexOf(0x0a); - bytesSinceNewline = - idx === -1 ? bytesSinceNewline + chunk.length : chunk.length - idx - 1; - if (bytesSinceNewline > MAX_REQUEST_LINE_BYTES) { - // No error argument: nothing useful can be written back on a socket - // that's mid-way through an oversized line; just drop it. - socket.destroy(); + if (rejected) return; + let start = 0; + for (;;) { + const idx = chunk.indexOf(0x0a, start); + bytesSinceNewline += (idx === -1 ? chunk.length : idx) - start; + if (bytesSinceNewline > MAX_REQUEST_LINE_BYTES) { + rejected = true; + // No error argument: nothing useful can be written back on a socket + // that's mid-way through an oversized line; just drop it. + socket.destroy(); + return; + } + if (idx === -1) return; + bytesSinceNewline = 0; + start = idx + 1; } }); const rl = createInterface({ input: socket, crlfDelay: Infinity }); @@ -131,6 +144,10 @@ export function acceptDaemonConnection( const elicitationChannel = new ConnectionElicitationChannel(socket); rl.on("line", (line) => { void (async () => { + // readline sees the same chunks as the cap enforcement above, so an + // oversized-but-terminated line can still surface here in the same + // tick the connection was rejected — never hand it to a handler. + if (rejected) return; if (elicitationChannel.tryConsumeLine(line)) return; let request: DaemonRequest; try { diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 28c5babe03..4f12ba2521 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -9,7 +9,7 @@ Connect to an MCP server once, then run many commands against that named connection. ```bash -mcpdo connect ./path/to/server.json # config-file entry +mcpdo connect entry-name --config ./mcp.json # entry from a config file mcpdo connect https://example.com/mcp # ad-hoc HTTP/SSE target mcpdo connect node server.js # ad-hoc stdio target diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 49cc48c088..3830ae1905 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -90,7 +90,7 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) | Connection | `connect` (`--relogin`), `disconnect`, `connections/list`, `connections/use` | | Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | | Daemon | `private`, `daemon status`, `daemon stop` | -| MCP | `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` | +| MCP | `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` (`initialize` is deliberately not registered — connection metadata comes from `connections/show`) | **Globals (before subcommand):** `--format text|json`, `--plain`, `--connection `, `--catalog` / `--config`, `--stored-auth-only`. From 08b133d1c6f13b6b871abd3295d2030d13166875 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 13:55:55 -0700 Subject: [PATCH 11/69] fix(daemon-cli): address Copilot review round 2 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispatch: chain stream writes and await the chain before returning, so mcp-bin's process.exit can't truncate a pending stdout write on piped or backpressured output; write errors stay non-fatal as before - sanitize: isSafeLinkTarget scheme allowlist (https/http) for OSC 8 hyperlinks; format-human and URL-mode elicitation render every other scheme (file:, custom protocol handlers) as plain text - paths: fail closed unless the predictable $TMPDIR/mcp-conn- root is a real directory owned by the current user, and tighten a loose mode fatally instead of best-effort — a shared-/tmp user can no longer plant the root (dir or symlink) and keep write control over socket/token paths Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../daemon-cli/__tests__/daemon-paths.test.ts | 28 ++++++++ clients/daemon-cli/__tests__/dispatch.test.ts | 69 +++++++++++++++++++ .../__tests__/elicitation-prompt.test.ts | 21 ++++++ .../__tests__/format-connection.test.ts | 19 +++++ clients/daemon-cli/__tests__/sanitize.test.ts | 23 ++++++- clients/daemon-cli/src/connection/dispatch.ts | 23 +++++-- .../src/connection/elicitation-prompt.ts | 8 ++- .../daemon-cli/src/connection/format-human.ts | 5 +- clients/daemon-cli/src/connection/sanitize.ts | 17 +++++ clients/daemon-cli/src/daemon/paths.ts | 38 +++++++++- 10 files changed, 240 insertions(+), 11 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-paths.test.ts b/clients/daemon-cli/__tests__/daemon-paths.test.ts index 02bcbed491..b16621a018 100644 --- a/clients/daemon-cli/__tests__/daemon-paths.test.ts +++ b/clients/daemon-cli/__tests__/daemon-paths.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { assertSocketPathWithinLimit, + assertTrustedPrivateRoot, createPrivateDaemonDir, ensureDaemonDir, getDaemonDir, @@ -81,6 +82,33 @@ describe("daemon paths", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("createPrivateDaemonDir refuses a symlinked mcp-conn root", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-sym-")); + setEnv("TMPDIR", tmp + path.sep); + // Another user pre-planting the predictable root as a symlink to a dir + // they control must fail closed, not be adopted by recursive mkdir. + const target = path.join(tmp, "attacker-controlled"); + fs.mkdirSync(target, { mode: 0o700 }); + fs.symlinkSync(target, path.join(tmp, `mcp-conn-${process.getuid!()}`)); + expect(() => createPrivateDaemonDir()).toThrow(/not a directory/); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("assertTrustedPrivateRoot tightens a loose pre-existing root", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-loose-")); + const root = path.join(tmp, "root"); + fs.mkdirSync(root, { mode: 0o755 }); + assertTrustedPrivateRoot(root); + expect(fs.statSync(root).mode & 0o777).toBe(0o700); + // A file in the root's place fails closed too. + const file = path.join(tmp, "not-a-dir"); + fs.writeFileSync(file, ""); + expect(() => assertTrustedPrivateRoot(file)).toThrow(/not a directory/); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("assertSocketPathWithinLimit rejects paths over the sun_path limit", () => { expect(() => assertSocketPathWithinLimit("/tmp/short/daemon.sock"), diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index c7e7662b13..7f9719b643 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -15,6 +15,31 @@ vi.mock("../src/connection/elicitation-prompt.js", () => ({ promptElicitation: (...args: unknown[]) => promptElicitation(...args), })); +// Pass-through wrapper so tests can delay writes and observe completion +// order (the stream path must flush queued writes before returning). +const writeDelayMs = { value: 0 }; +const writeReject = { value: false }; +const writeCompletions: unknown[] = []; +vi.mock("../src/connection/format-connection.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../src/connection/format-connection.js") + >(); + return { + ...actual, + writeConnectionOutput: async (...args: unknown[]) => { + if (writeReject.value) throw new Error("stdout write failed"); + if (writeDelayMs.value > 0) { + await new Promise((r) => setTimeout(r, writeDelayMs.value)); + } + await ( + actual.writeConnectionOutput as (...a: unknown[]) => Promise + )(...args); + writeCompletions.push(args[1]); + }, + }; +}); + describe("dispatchConnectionRpc", () => { let stdout: string; let originalWrite: typeof process.stdout.write; @@ -34,6 +59,9 @@ describe("dispatchConnectionRpc", () => { callDaemon.mockReset(); streamDaemon.mockReset(); promptElicitation.mockReset(); + writeDelayMs.value = 0; + writeReject.value = false; + writeCompletions.length = 0; }); afterEach(() => { @@ -111,6 +139,47 @@ describe("dispatchConnectionRpc", () => { expect(streamDaemon).toHaveBeenCalled(); }); + it("flushes queued stream writes before returning", async () => { + // Regression: stream writes were fire-and-forget, so mcp-bin's + // process.exit() right after dispatch resolved could truncate the final + // event when stdout is piped or backpressured. + writeDelayMs.value = 10; + streamDaemon.mockImplementation( + async (_params: unknown, opts: { onData: (d: unknown) => void }) => { + opts.onData({ type: "subscribed", uri: "test://one" }); + opts.onData({ type: "subscribed", uri: "test://two" }); + }, + ); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "logging/tail", + {}, + { requireExplicit: false, connection: "@s" }, + ); + expect(writeCompletions.length).toBe(2); + expect(stdout).toContain("test://two"); + }); + + it("keeps stream write failures non-fatal, as when they were fire-and-forget", async () => { + writeReject.value = true; + streamDaemon.mockImplementation( + async (_params: unknown, opts: { onData: (d: unknown) => void }) => { + opts.onData({ type: "subscribed", uri: "test://x" }); + opts.onData({ type: "subscribed", uri: "test://y" }); + }, + ); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await expect( + dispatchConnectionRpc( + "logging/tail", + {}, + { requireExplicit: false, connection: "@s" }, + ), + ).resolves.toBeUndefined(); + }); + it("wires SIGINT/SIGTERM abort for the general rpc path (not just streams)", async () => { callDaemon.mockImplementation( async (_op: string, _params: unknown, opts: { signal?: AbortSignal }) => { diff --git a/clients/daemon-cli/__tests__/elicitation-prompt.test.ts b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts index 1db33ba6e7..5d4b420c41 100644 --- a/clients/daemon-cli/__tests__/elicitation-prompt.test.ts +++ b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts @@ -176,6 +176,27 @@ describe("promptElicitation", () => { expect(stderr).toContain("https://example.com/confirm"); }); + it("renders only allowlisted schemes as OSC 8 links in URL mode", async () => { + question.mockResolvedValue(""); + const ansi = createStyle(true); + const { promptElicitation } = + await import("../src/connection/elicitation-prompt.js"); + + await promptElicitation(urlFrame(), { interactive: true, style: ansi }); + expect(stderr).toContain("\u001b]8;;https://example.com/confirm"); + + stderr = ""; + const answer = await promptElicitation( + urlFrame({ url: "file:///etc/passwd" }), + { interactive: true, style: ansi }, + ); + // A server-supplied file:/custom-handler URL is shown as plain text — + // never as a clickable link inviting the local protocol handler. + expect(answer.action).toBe("accept"); + expect(stderr).not.toContain("]8;;"); + expect(stderr).toContain("file:///etc/passwd"); + }); + it("cancels when the interactive user types 'c'", async () => { question.mockResolvedValue("c"); const { promptElicitation } = diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index 30d1287974..be7f036967 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -898,4 +898,23 @@ describe("format-human ANSI styling", () => { expect(log).toContain("\u001b[31m"); expect(log).toContain("boom"); }); + + it("hyperlinks only allowlisted schemes as OSC 8", () => { + const s = createStyle(true); + const out = formatResourcesHuman( + [ + { uri: "https://example.com/r", name: "web" }, + { uri: "file:///etc/passwd", name: "local" }, + { uri: "vscode://malicious/payload", name: "custom" }, + ], + s, + ); + // https renders as a clickable link; file:/custom-handler URIs must not + // invite the terminal to invoke a local protocol handler. + expect(out).toContain("\u001b]8;;https://example.com/r"); + expect(out).not.toContain("]8;;file://"); + expect(out).not.toContain("]8;;vscode://"); + expect(out).toContain("file:///etc/passwd"); + expect(out).toContain("vscode://malicious/payload"); + }); }); diff --git a/clients/daemon-cli/__tests__/sanitize.test.ts b/clients/daemon-cli/__tests__/sanitize.test.ts index 6b9abfe12d..db5f83a37b 100644 --- a/clients/daemon-cli/__tests__/sanitize.test.ts +++ b/clients/daemon-cli/__tests__/sanitize.test.ts @@ -5,7 +5,11 @@ * rewriting, OSC 8 hyperlink breakout). */ import { describe, expect, it } from "vitest"; -import { sanitizeDeep, sanitizeText } from "../src/connection/sanitize.js"; +import { + isSafeLinkTarget, + sanitizeDeep, + sanitizeText, +} from "../src/connection/sanitize.js"; describe("sanitizeText", () => { it("neutralizes an OSC 52 clipboard-write sequence", () => { @@ -74,3 +78,20 @@ describe("sanitizeDeep", () => { expect(out.text).toBe("esc\u241b"); }); }); + +describe("isSafeLinkTarget", () => { + it("allows only http(s) URLs as OSC 8 link targets", () => { + expect(isSafeLinkTarget("https://example.com/x")).toBe(true); + expect(isSafeLinkTarget("http://localhost:3001/mcp")).toBe(true); + expect(isSafeLinkTarget("file:///etc/passwd")).toBe(false); + expect(isSafeLinkTarget("javascript:alert(1)")).toBe(false); + expect(isSafeLinkTarget("vscode://malicious/payload")).toBe(false); + expect(isSafeLinkTarget("customproto://x")).toBe(false); + }); + + it("rejects strings that don't parse as URLs", () => { + expect(isSafeLinkTarget("not a url")).toBe(false); + expect(isSafeLinkTarget("")).toBe(false); + expect(isSafeLinkTarget("example.com/no-scheme")).toBe(false); + }); +}); diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index 5fae29b1b8..d08a79e00b 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -51,23 +51,34 @@ export async function dispatchConnectionRpc( const onSignal = () => ac.abort(); process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); + // Stream writes are chained and awaited before returning: mcp-bin calls + // process.exit() right after, which truncates a still-pending stdout + // write when output is piped or backpressured. + let writeChain: Promise = Promise.resolve(); try { await streamDaemon(params, { socketPath, signal: ac.signal, onData: (data) => { - void writeConnectionOutput( - { format, style }, - { - kind: "stream-event", - data, - }, + writeChain = writeChain.then(() => + writeConnectionOutput( + { format, style }, + { + kind: "stream-event", + data, + }, + ), ); + // Detached observer: prevents an unhandled rejection while the + // stream is still running; write errors stay non-fatal, as they + // were when these writes were fire-and-forget. + writeChain.catch(() => {}); }, }); } finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); + await writeChain.catch(() => {}); } return; } diff --git a/clients/daemon-cli/src/connection/elicitation-prompt.ts b/clients/daemon-cli/src/connection/elicitation-prompt.ts index d8379c10ba..766f9ffb9d 100644 --- a/clients/daemon-cli/src/connection/elicitation-prompt.ts +++ b/clients/daemon-cli/src/connection/elicitation-prompt.ts @@ -21,7 +21,7 @@ import type { } from "../daemon/protocol.js"; import { parseFormSchema } from "./form-schema.js"; import { promptForm, watchForClose } from "./form-prompt.js"; -import { sanitizeText } from "./sanitize.js"; +import { isSafeLinkTarget, sanitizeText } from "./sanitize.js"; export type PromptElicitationOpts = { /** @@ -147,7 +147,11 @@ export async function promptElicitation( message + "\n" + " " + - style.link(url ?? "", url) + + // Only allowlisted schemes render as a clickable OSC 8 link; a server + // supplying file:/custom-handler URLs gets plain text (see sanitize.ts). + (url !== undefined && isSafeLinkTarget(url) + ? style.link(url, url) + : (url ?? "")) + "\n\n", ); diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 0685d0b09d..cd8aa8e5e8 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -4,6 +4,7 @@ */ import { PLAIN, type Style } from "@inspector/cli/style.js"; +import { isSafeLinkTarget } from "./sanitize.js"; type JsonObject = Record; @@ -88,7 +89,9 @@ function descSuffix(style: Style, description: unknown): string { function formatUri(style: Style, uri: string): string { if (!uri) return uri; - if (uri.includes("://")) return style.link(uri); + // Only allowlisted schemes become clickable OSC 8 links (see sanitize.ts); + // file:/custom-handler URIs from a server render as plain colored text. + if (isSafeLinkTarget(uri)) return style.link(uri); return style.cyan(uri); } diff --git a/clients/daemon-cli/src/connection/sanitize.ts b/clients/daemon-cli/src/connection/sanitize.ts index a7ba752bbb..64e7ea546e 100644 --- a/clients/daemon-cli/src/connection/sanitize.ts +++ b/clients/daemon-cli/src/connection/sanitize.ts @@ -49,3 +49,20 @@ export function sanitizeDeep(value: T): T { } return value; } + +/** + * Schemes a server-supplied URI may be rendered as an OSC 8 hyperlink. + * A hyperlink is an invitation for the user to invoke the local handler for + * the scheme, so an untrusted MCP server only gets the web ones: `file:`, + * custom protocol handlers, `javascript:` and the rest render as plain text. + */ +const SAFE_LINK_SCHEMES = new Set(["https:", "http:"]); + +/** True when `uri` parses and its scheme is on the OSC 8 allowlist. */ +export function isSafeLinkTarget(uri: string): boolean { + try { + return SAFE_LINK_SCHEMES.has(new URL(uri).protocol); + } catch { + return false; + } +} diff --git a/clients/daemon-cli/src/daemon/paths.ts b/clients/daemon-cli/src/daemon/paths.ts index 75ea566b4a..a7db0e0d6d 100644 --- a/clients/daemon-cli/src/daemon/paths.ts +++ b/clients/daemon-cli/src/daemon/paths.ts @@ -46,11 +46,17 @@ export function createPrivateDaemonDir(): string { const uid = typeof process.getuid === "function" ? process.getuid() : "u"; const root = path.join(os.tmpdir(), `mcp-conn-${uid}`); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); + // The tmpdir parent is world-writable on shared machines, and mkdir with + // `recursive: true` succeeds silently over a pre-existing entry — never + // trust a root another user could have planted (dir or symlink) before + // this user's first run. + assertTrustedPrivateRoot(root); const id = randomBytes(4).toString("hex"); const dir = path.join(root, id); + // Non-recursive mkdir is exclusive: an existing entry (however unlikely + // under a now-verified 0700 root) throws instead of being adopted. fs.mkdirSync(dir, { mode: 0o700 }); try { - fs.chmodSync(root, 0o700); fs.chmodSync(dir, 0o700); } catch { // best-effort on platforms that ignore mode @@ -58,6 +64,36 @@ export function createPrivateDaemonDir(): string { return dir; } +/** + * Fail closed unless `dir` is a real directory (not a symlink) owned by the + * current user, and tighten its mode to 0700. On a shared `/tmp`, another + * user who pre-created the predictable `mcp-conn-` path — or planted a + * symlink there — would otherwise keep write control over where the daemon's + * socket and token land. No-op on Windows (no getuid/UNIX mode semantics). + * Exported for tests. + */ +export function assertTrustedPrivateRoot(dir: string): void { + /* v8 ignore next -- Windows-only: no getuid */ + if (typeof process.getuid !== "function") return; + const st = fs.lstatSync(dir); + if (!st.isDirectory()) { + throw new Error( + `Refusing to use ${dir}: not a directory (a file or symlink was planted in its place).`, + ); + } + /* v8 ignore next 5 -- requires a second uid to create the dir; untestable without root */ + if (st.uid !== process.getuid()) { + throw new Error( + `Refusing to use ${dir}: owned by uid ${st.uid}, not the current user (uid ${process.getuid()}).`, + ); + } + if ((st.mode & 0o077) !== 0) { + // We own it, so chmod either succeeds or throws — a failure here must + // stay fatal rather than leaving a group/other-accessible daemon dir. + fs.chmodSync(dir, 0o700); + } +} + export function getDaemonSocketPath(dir: string = getDaemonDir()): string { return path.join(dir, "daemon.sock"); } From b484c8327f942c1cbb9780ecd85b2c5760868fd4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 14:24:38 -0700 Subject: [PATCH 12/69] fix(daemon-cli): address Copilot review round 3 on #1783 - connections.ts: serialize connect/disconnect per connection name with a promise mutex (withNameLock). Concurrent connects for the same unused name could both pass the reconnect check and race through connections.set, leaving the loser's live client untracked and undisconnectable. The reconnect path uses an internal disconnectLocked to avoid self-deadlock, and queued duplicate disconnects re-resolve under the lock so they fail with connection_not_found instead of tearing down a successor. - daemon-connections.test.ts: regression test for concurrent same-name connects (verified to fail against the pre-lock code). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-connections.test.ts | 50 ++++++++++++++++++ clients/daemon-cli/src/daemon/connections.ts | 51 +++++++++++++++++-- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index a43b37fd92..cc67fe8423 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -243,6 +243,56 @@ describe("ConnectionRegistry", () => { expect(DEFAULT_IDLE_MS).toBe(60_000); }); + it("serializes concurrent connects for the same name so the replaced client is torn down, not leaked", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + // Slow connect widens the check→set window that raced pre-lock. + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 25)), + ); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + try { + const params = { + name: "dup", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + } as const; + const [a, b] = await Promise.all([ + registry.connect(params), + registry.connect(params), + ]); + expect(a.name).toBe("dup"); + expect(b.name).toBe("dup"); + // Exactly one tracked connection; the loser of the race was + // disconnected by the serialized reconnect path, not orphaned. + expect(registry.connectionCount()).toBe(1); + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + await registry.disconnect("dup", false); + expect(disconnectSpy).toHaveBeenCalledTimes(2); + expect(registry.connectionCount()).toBe(0); + // A queued duplicate disconnect fails cleanly rather than tearing + // down a successor's connection. + await expect(registry.disconnect("dup", false)).rejects.toThrow( + /not found/, + ); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const { NodeOAuthStorage, resetNodeOAuthStorageCache } = diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 8c0f741794..c8d3653410 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -78,6 +78,32 @@ export class ConnectionRegistry { this.onIdle = handler; } + /** + * Per-name serialization for connect/disconnect. Both hold `connections` + * state across awaits; two simultaneous connects for the same + * previously-unused name would otherwise both pass the reconnect check and + * race through `connections.set`, leaving the loser's live client + * untracked and undisconnectable. + */ + private readonly nameLocks = new Map>(); + + private async withNameLock( + name: string, + fn: () => Promise, + ): Promise { + const prev = this.nameLocks.get(name) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => (release = resolve)); + this.nameLocks.set(name, current); + await prev; + try { + return await fn(); + } finally { + release(); + if (this.nameLocks.get(name) === current) this.nameLocks.delete(name); + } + } + /** * Arm the idle shutdown timer when there are no connections. * Called at daemon start so a spawn that never connects still self-reaps, @@ -197,13 +223,22 @@ export class ConnectionRegistry { serverConfig: MCPServerConfig; serverSettings?: InspectorServerSettings; serverIdentity: string; + }): Promise { + return this.withNameLock(params.name, () => this.connectLocked(params)); + } + + private async connectLocked(params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; }): Promise { this.clearIdleTimer(); try { if (this.connections.has(params.name)) { // Reconnect: tear down the previous client first. - await this.disconnect(params.name, false); + await this.disconnectLocked(params.name); } // Front-end authorize / auth/clear write oauth.json in another process. @@ -265,8 +300,18 @@ export class ConnectionRegistry { name: string | undefined, requireExplicit: boolean | undefined, ): Promise<{ name: string }> { - const connection = this.resolve(name, requireExplicit); - const connectionName = connection.name; + const connectionName = this.resolve(name, requireExplicit).name; + return this.withNameLock(connectionName, () => + this.disconnectLocked(connectionName), + ); + } + + private async disconnectLocked( + connectionName: string, + ): Promise<{ name: string }> { + // Re-resolve under the lock: a queued duplicate disconnect must fail + // with connection_not_found, not tear down a successor's connection. + const connection = this.resolve(connectionName, true); this.connections.delete(connectionName); if (this.mruName === connectionName) { // Promote the next most-recently-accessed connection, if any. From e7a10722722800897a64dcaa70b2c73e3baecb3b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 15:21:49 -0700 Subject: [PATCH 13/69] fix: address Copilot review round 4 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon-cli paths.ts: ensureDaemonDir now re-validates and tightens a pre-existing daemon directory via assertTrustedPrivateRoot — mkdirSync never re-modes an existing dir, so a pre-existing ~/.mcp-inspector at 0755 (or a planted symlink) previously bypassed the 0700 trust model. Added tests (tightens loose dir, rejects symlink). - scripts/pack-and-verify.mjs: the installed tarball check now also resolves and runs the mcpdo bin (--help + daemon-free servers/list against the throwaway catalog, daemon dir isolated into the consumer). - clients/cli method-types.ts: rename leftover SessionRpcMethod type to ConnectionRpcMethod. - specification/v2_catalog_launch_config.md: #1432 related-issue row updated from Open to Closed (#1783). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/cli/src/handlers/method-types.ts | 2 +- .../daemon-cli/__tests__/daemon-paths.test.ts | 18 ++++++++ clients/daemon-cli/src/daemon/paths.ts | 8 +++- scripts/pack-and-verify.mjs | 44 +++++++++++++++++++ specification/v2_catalog_launch_config.md | 2 +- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index c8a101dac7..3a6fee7ea4 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -130,7 +130,7 @@ export const CONNECTION_RPC_METHODS = [ "skills/get", ] as const; -export type SessionRpcMethod = (typeof CONNECTION_RPC_METHODS)[number]; +export type ConnectionRpcMethod = (typeof CONNECTION_RPC_METHODS)[number]; /** * Methods accepted by `mcp-inspector --cli` (plus catalog-only diff --git a/clients/daemon-cli/__tests__/daemon-paths.test.ts b/clients/daemon-cli/__tests__/daemon-paths.test.ts index b16621a018..c88035cec6 100644 --- a/clients/daemon-cli/__tests__/daemon-paths.test.ts +++ b/clients/daemon-cli/__tests__/daemon-paths.test.ts @@ -65,6 +65,24 @@ describe("daemon paths", () => { fs.rmSync(dir, { recursive: true, force: true }); }); + it("tightens a pre-existing loose daemon directory to 0700 and rejects symlinks", () => { + // mkdirSync never re-modes an existing dir; ~/.mcp-inspector commonly + // pre-exists at 0755, so ensureDaemonDir must tighten it itself. + const base = fs.mkdtempSync(path.join(os.tmpdir(), "daemon-tighten-")); + const loose = path.join(base, "loose"); + fs.mkdirSync(loose, { mode: 0o755 }); + fs.chmodSync(loose, 0o755); + ensureDaemonDir(loose); + expect(fs.statSync(loose).mode & 0o077).toBe(0); + + const target = path.join(base, "target"); + fs.mkdirSync(target, { mode: 0o700 }); + const link = path.join(base, "link"); + fs.symlinkSync(target, link); + expect(() => ensureDaemonDir(link)).toThrow(/not a directory/); + fs.rmSync(base, { recursive: true, force: true }); + }); + it("createPrivateDaemonDir nests under a short 0700 tmpdir layout", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-t-")); setEnv("TMPDIR", tmp + path.sep); diff --git a/clients/daemon-cli/src/daemon/paths.ts b/clients/daemon-cli/src/daemon/paths.ts index a7db0e0d6d..7199ef0d70 100644 --- a/clients/daemon-cli/src/daemon/paths.ts +++ b/clients/daemon-cli/src/daemon/paths.ts @@ -142,7 +142,13 @@ export function assertSocketPathWithinLimit(socketPath: string): void { /** Ensure the daemon directory exists before binding the socket. * Created 0700: the socket lives inside, so its own mode never has to be - * the enforcement boundary (BSDs are inconsistent about socket modes). */ + * the enforcement boundary (BSDs are inconsistent about socket modes). + * `mkdirSync` never changes the mode of a pre-existing directory — and the + * default `~/.mcp-inspector` commonly already exists at 0755 from other + * inspector components — so an existing directory is re-validated and + * tightened with the same symlink/ownership/mode checks as the private + * tmp root. */ export function ensureDaemonDir(dir: string = getDaemonDir()): void { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + assertTrustedPrivateRoot(dir); } diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 04538775c5..17b5b13e28 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -391,6 +391,50 @@ try { fail(`\`--cli … tools/list\` missing expected "echo" tool`); } + // 4b². The tarball also ships the `mcpdo` connection-CLI bin (#1783). Verify + // the installed shim resolves and runs: `--help` (dispatch/build + // resolution) plus a daemon-free command (`servers/list` against the + // same catalog — no daemon spawn, no MCP connection), so a wrong bin + // path or an incompletely packed daemon-cli build fails the gate. + step("verifying installed `mcpdo` (--help, daemon-free servers/list)..."); + const mcpdoBin = join( + work, + "node_modules", + ".bin", + process.platform === "win32" ? "mcpdo.cmd" : "mcpdo", + ); + if (!existsSync(mcpdoBin)) { + fail(`installed \`mcpdo\` bin not found at ${mcpdoBin}`); + } + const runMcpdo = (args, extraEnv = {}) => { + const r = spawnSync(shellArgs([mcpdoBin])[0], shellArgs(args), { + cwd: work, + encoding: "utf8", + env: { ...process.env, ...extraEnv }, + shell: WIN_SHELL, + }); + return { status: r.status, output: `${r.stdout ?? ""}${r.stderr ?? ""}` }; + }; + const mcpdoHelp = runMcpdo(["--help"]); + if (mcpdoHelp.status !== 0 || !mcpdoHelp.output.includes("Usage: mcpdo")) { + fail( + `\`mcpdo --help\` exited ${mcpdoHelp.status} or missing usage banner\n` + + mcpdoHelp.output.slice(0, 800), + ); + } + // Point the daemon dir into the throwaway consumer so the check never sees + // (or touches) a real daemon on the host. + const mcpdoServers = runMcpdo( + ["servers/list", "--catalog", catalogPath, "--plain"], + { MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon") }, + ); + if (mcpdoServers.status !== 0 || !mcpdoServers.output.includes("test")) { + fail( + `\`mcpdo servers/list\` exited ${mcpdoServers.status} or missing "test" entry\n` + + mcpdoServers.output.slice(0, 800), + ); + } + // 4c. Prod `--web` boot from the installed package — THE critical packaging // path: the runner must locate and serve the shipped `dist` (not rebuild // it) and inject the auth token. Run non-blocking and poll `/`. diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index 90ac5e7f04..6b23307276 100644 --- a/specification/v2_catalog_launch_config.md +++ b/specification/v2_catalog_launch_config.md @@ -512,7 +512,7 @@ G1, G4, and launcher details: [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md). | [#1183](https://github.com/modelcontextprotocol/inspector/issues/1183) — auto-connect | Open | UC5 web ergonomics | | [#1348](https://github.com/modelcontextprotocol/inspector/issues/1348) — import from other clients | Open | UC2 web UI | | [#1435](https://github.com/modelcontextprotocol/inspector/issues/1435) — registry import | Open | UC2 registry path | -| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Open | Session CLI umbrella — as-built: [v2_cli_v2.md](v2_cli_v2.md) | +| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Closed (#1783) | Connection CLI umbrella — as-built: [v2_cli_v2.md](v2_cli_v2.md); `mcpdo` daemon CLI shipped in [#1783](https://github.com/modelcontextprotocol/inspector/pull/1783) | | [#1352](https://github.com/modelcontextprotocol/inspector/pull/1352) / [#1358](https://github.com/modelcontextprotocol/inspector/pull/1358) | Merged | Flat settings on disk | | [#1356](https://github.com/modelcontextprotocol/inspector/pull/1356) | Merged | Secrets in keychain | From fafb7cefa3dbd0273a6e1e43e33d9ad76e2aba59 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 16:08:34 -0700 Subject: [PATCH 14/69] fix: address Copilot review round 5 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon-cli form-schema.ts: reject empty choice arrays. An empty enum / oneOf / items.anyOf parsed into an enum field with zero options, so a required field rendered an unwinnable prompt (no choices, 1..0 range rejects every answer). Present-but-invalid enum (empty or non-string entries) now fails schema parse instead of degrading to a freeform string. Regression tests added. - web serverList.test.ts: cover persisted elicitCapability like the adjacent protocolEra/modernLogLevel fields — non-default round-trip, unknown-literal drop on read, and default ("both") omission on write. - skills/mcpdo/SKILL.md: qualify command examples with @entry-name and document that omitting the connection only works on an interactive TTY or with MCP_ALLOW_DEFAULT_CONNECTION=1 — unqualified examples fail in the non-TTY agent shells the skill targets. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../daemon-cli/__tests__/form-schema.test.ts | 41 ++++++++++++++ .../daemon-cli/src/connection/form-schema.ts | 18 +++++-- .../web/src/test/core/mcp/serverList.test.ts | 54 +++++++++++++++++++ skills/mcpdo/SKILL.md | 24 +++++---- 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/clients/daemon-cli/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts index d217dfd4c2..2700517e88 100644 --- a/clients/daemon-cli/__tests__/form-schema.test.ts +++ b/clients/daemon-cli/__tests__/form-schema.test.ts @@ -167,6 +167,47 @@ describe("parseFormSchema", () => { ).toBeNull(); }); + it("returns null for empty choice arrays (unwinnable required prompt otherwise)", () => { + // A required field with zero options renders no choices and rejects + // every answer (1..0 range) — treat the schema as malformed instead. + expect( + parseFormSchema({ + type: "object", + properties: { color: { type: "string", enum: [] } }, + required: ["color"], + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { color: { type: "string", oneOf: [] } }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { + colors: { type: "array", items: { type: "string", enum: [] } }, + }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { + colors: { type: "array", items: { type: "string", anyOf: [] } }, + }, + }), + ).toBeNull(); + // Non-string enum entries stay malformed too (not a freeform string). + expect( + parseFormSchema({ + type: "object", + properties: { color: { type: "string", enum: [1, 2] } }, + }), + ).toBeNull(); + }); + it("parses a multi-select enum without titles, with min/maxItems and default", () => { const fields = parseFormSchema({ type: "object", diff --git a/clients/daemon-cli/src/connection/form-schema.ts b/clients/daemon-cli/src/connection/form-schema.ts index 7a6966362b..723d8e0d52 100644 --- a/clients/daemon-cli/src/connection/form-schema.ts +++ b/clients/daemon-cli/src/connection/form-schema.ts @@ -51,14 +51,20 @@ function isRecord(value: unknown): value is Record { } function parseChoicesFromEnum(value: unknown): Choice[] | undefined { - if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((v) => typeof v !== "string") + ) { return undefined; } return (value as string[]).map((v) => ({ value: v, label: v })); } function parseChoicesFromOneOf(value: unknown): Choice[] | undefined { - if (!Array.isArray(value)) return undefined; + // Empty choice sets are rejected (like empty `enum`): a required field + // with zero options would render an unwinnable prompt. + if (!Array.isArray(value) || value.length === 0) return undefined; const choices: Choice[] = []; for (const entry of value) { if (!isRecord(entry) || typeof entry.const !== "string") return undefined; @@ -92,8 +98,12 @@ function parseField(prop: unknown): FieldExtra | null { } if (type === "string") { - const enumChoices = parseChoicesFromEnum(prop.enum); - if (enumChoices) { + if (prop.enum !== undefined) { + const enumChoices = parseChoicesFromEnum(prop.enum); + // Present-but-invalid (non-string entries or an empty list) is a + // malformed schema, not a freeform string field: an empty required + // choice prompt would be unwinnable. + if (!enumChoices) return null; return { kind: "enum", choices: enumChoices, diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index d660ecd00e..6dd9dc9782 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -610,6 +610,60 @@ describe("serverEntriesToMcpConfig", () => { expect("protocolEra" in (round.mcpServers["era-legacy"] ?? {})).toBe(false); }); + it("round-trips elicitCapability: lifts a non-default value to settings and back to disk (#1783)", () => { + const original: MCPConfig = { + mcpServers: { + "elicit-url": { + type: "streamable-http", + url: "https://x.test/mcp", + elicitCapability: "url", + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.elicitCapability).toBe("url"); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect(round).toEqual(original); + }); + + it("drops an unknown elicitCapability literal on read (hand-edited file)", () => { + // Like protocolEra: garbage from a hand-edited mcp.json is dropped here + // rather than reaching the connect-time capability wiring. + const badElicit: object = { elicitCapability: "everything" }; + const original: MCPConfig = { + mcpServers: { + "elicit-bad": { + type: "streamable-http", + url: "https://x.test/mcp", + ...badElicit, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.elicitCapability).toBeUndefined(); + }); + + it("omits elicitCapability from disk when it equals the default (both)", () => { + // "both" is the default — writing it back must NOT inject the field. + // A benign inspector field keeps `settings` materialized. + const original: MCPConfig = { + mcpServers: { + "elicit-both": { + type: "streamable-http", + url: "https://x.test/mcp", + elicitCapability: "both", + connectionTimeout: 5000, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.elicitCapability).toBe("both"); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect("elicitCapability" in (round.mcpServers["elicit-both"] ?? {})).toBe( + false, + ); + }); + it("round-trips modernLogLevel: lifts a non-default value to settings and back to disk (#1629)", () => { const original: MCPConfig = { mcpServers: { diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 4f12ba2521..233df0c157 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -13,16 +13,15 @@ mcpdo connect entry-name --config ./mcp.json # entry from a config file mcpdo connect https://example.com/mcp # ad-hoc HTTP/SSE target mcpdo connect node server.js # ad-hoc stdio target -mcpdo tools/list -mcpdo tools/call arg:=value -mcpdo resources/list -mcpdo resources/read -mcpdo prompts/list +mcpdo @entry-name tools/list +mcpdo @entry-name tools/call arg:=value +mcpdo @entry-name resources/list +mcpdo @entry-name resources/read +mcpdo @entry-name prompts/list -mcpdo @my-connection tools/list # target a specific connection -mcpdo --connection my-connection tools/list +mcpdo --connection entry-name tools/list # flag form of @entry-name -mcpdo disconnect +mcpdo disconnect entry-name ``` Run `mcpdo help` or `mcpdo --help` for the full, authoritative list of @@ -33,8 +32,11 @@ commands and flags. - `--format json` outputs JSON; the default, `--format text`, is human-readable. - `mcpdo connections/list` shows open connections; `@name` (prefix on any command) - or `--connection ` (shorthand `--conn`) selects one explicitly when the most-recently-used - connection isn't the right one. + or `--connection ` (shorthand `--conn`) selects one explicitly. Always + qualify commands this way from an agent shell: with non-interactive (non-TTY) + stdin, mcpdo requires an explicit connection and errors without one. Omitting + it falls back to the most-recently-used connection only on an interactive + TTY, or anywhere when `MCP_ALLOW_DEFAULT_CONNECTION=1` is set. - A connected connection persists across separate `mcpdo` invocations — no need to reconnect before each command. `mcpdo disconnect` ends one connection; `mcpdo daemon stop` resets everything. @@ -43,7 +45,7 @@ commands and flags. overrides); `mcpdo connect ` connects an ad-hoc target with defaults. - Auth is handled automatically at connect time and stored for reuse (`mcpdo - auth/list` / `mcpdo auth/clear`); nothing extra is needed for authenticated +auth/list` / `mcpdo auth/clear`); nothing extra is needed for authenticated HTTP servers beyond `connect` and completing the browser flow if prompted. - If a server asks a question mid-call (elicitation), mcpdo prompts interactively by default — including over a plain non-TTY stdin, so an From ca4ed9092a78ee3370f7d261a76fc5da9b69a0a2 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 16:53:31 -0700 Subject: [PATCH 15/69] fix(daemon-cli): address Copilot review round 6 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - form-prompt.ts: strict whole-token integer parsing for choice answers — parseInt accepted "1abc" as choice 1; and a single-select now requires exactly one token instead of silently submitting only the first of "1,2". Both re-prompt. Regression test added. - stored-auth.ts: aggregate hasTokens/hasRefreshToken across the legacy slot and every byIssuer slot — first-match-wins made the refresh flag depend on object insertion order. Fixture + assertions updated with a multi-issuer entry. - daemon server.ts: stop() memoizes the in-flight cleanup promise so a repeated signal awaits the original teardown instead of resolving immediately and exiting mid-cleanup, stranding socket/token/lock. Regression test added. - daemon protocol.ts: correct the token doc comment — every daemon requires the token (shared daemons publish it to daemon.token); it is optional only at the wire/type boundary so a missing token parses and is rejected. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/connection-stored-auth.test.ts | 29 +++++++++++++++++-- .../__tests__/daemon-coverage.test.ts | 14 +++++++++ .../daemon-cli/__tests__/form-prompt.test.ts | 20 +++++++++++++ .../daemon-cli/src/connection/form-prompt.ts | 13 ++++++++- .../daemon-cli/src/connection/stored-auth.ts | 19 +++++------- clients/daemon-cli/src/daemon/protocol.ts | 8 +++-- clients/daemon-cli/src/daemon/server.ts | 13 +++++++-- 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/clients/daemon-cli/__tests__/connection-stored-auth.test.ts b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts index 708e55cc37..1079e578a2 100644 --- a/clients/daemon-cli/__tests__/connection-stored-auth.test.ts +++ b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts @@ -48,6 +48,22 @@ function writeOAuthFixture(dir: string): string { "https://as.example/": {}, }, }, + "https://multi.example/mcp": { + // First issuer: access only. Second: access + refresh. The summary + // must aggregate across slots, not stop at the first access token. + byIssuer: { + "https://as-a.example/": { + tokens: { access_token: "a1", token_type: "Bearer" }, + }, + "https://as-b.example/": { + tokens: { + access_token: "a2", + token_type: "Bearer", + refresh_token: "r2", + }, + }, + }, + }, }, idpSessions: {}, }), @@ -88,6 +104,7 @@ describe("connection stored-auth helpers", () => { "https://empty.example/mcp", "https://example.com/mcp", "https://issuer-empty.example/mcp", + "https://multi.example/mcp", "https://nullish.example/mcp", "https://other.example/mcp", "https://stringish.example/mcp", @@ -102,6 +119,12 @@ describe("connection stored-auth helpers", () => { expect( list.servers.find((s) => s.url.includes("issuer-empty")), ).toMatchObject({ hasTokens: false, hasRefreshToken: false }); + // Aggregated across issuer slots: the refresh token lives in the second + // slot, so first-match-wins would have reported hasRefreshToken: false. + expect(list.servers.find((s) => s.url.includes("multi"))).toMatchObject({ + hasTokens: true, + hasRefreshToken: true, + }); expect( list.servers.find((s) => s.url.includes("example.com")), ).toMatchObject({ hasTokens: true, hasRefreshToken: true }); @@ -125,7 +148,7 @@ describe("connection stored-auth helpers", () => { ); const all = await clearAllStoredAuth(); - expect(all.cleared).toBe(5); + expect(all.cleared).toBe(6); list = await listStoredAuth(); expect(list.servers).toEqual([]); }); @@ -197,7 +220,7 @@ describe("mcp auth/list and auth/clear", () => { const body = JSON.parse(listed.stdout) as { servers: { url: string }[]; }; - expect(body.servers.length).toBe(6); + expect(body.servers.length).toBe(7); const cleared = await runMcp( ["auth/clear", "https://example.com/mcp", "--format", "json"], @@ -213,7 +236,7 @@ describe("mcp auth/list and auth/clear", () => { { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, ); expectCliSuccess(all); - expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 5 }); + expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 6 }); }); it("rejects --all without --yes when non-interactive", async () => { diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 780a6bc89c..76cfb97990 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -307,6 +307,20 @@ describe("daemon coverage", () => { server = undefined; }); + it("repeated stop() returns the same in-flight cleanup promise", async () => { + // A second SIGINT used to see `stopping` and resolve immediately, + // letting its caller process.exit() mid-teardown and strand the + // socket/token/lock. Both calls must await the same cleanup. + server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + await server.start(); + const first = server.stop("signal"); + const second = server.stop("signal"); + expect(second).toBe(first); + await first; + expect(fs.existsSync(server.socketPath)).toBe(false); + server = undefined; + }); + it("callDaemon times out a hung server", async () => { const d = freshDir(); const sock = path.join(d, "daemon.sock"); diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index 5816a53949..9ddcdb9849 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -260,6 +260,26 @@ describe("promptForm", () => { expect(stderr).toContain("Enter a number between 1 and 1"); }); + it("rejects malformed and multi-token single-select answers", async () => { + const field: FormField = { + name: "color", + required: true, + title: "Color", + kind: "enum", + choices: [ + { value: "red", label: "Red" }, + { value: "blue", label: "Blue" }, + ], + }; + // "1abc" must not be silently accepted as choice 1 (parseInt prefix), + // and "1,2" on a single-select must not silently submit only "red". + const rl = fakeRl(["1abc", "1,2", "2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { color: "blue" } }); + expect(stderr).toContain("Enter a number between 1 and 2"); + expect(stderr).toContain("Enter exactly one number"); + }); + it("omits an optional enum field left blank with no default", async () => { const field: FormField = { name: "color", diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index dd37586124..65c5c332b3 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -105,7 +105,12 @@ async function promptField( process.stderr.write(style.red(" This field is required.\n")); continue; } - const indices = raw.split(",").map((s) => Number.parseInt(s.trim(), 10)); + // Strict whole-token integers only: parseInt would accept "1abc" as 1, + // silently submitting a different answer than the user typed. + const tokens = raw.split(",").map((s) => s.trim()); + const indices = tokens.map((s) => + /^\d+$/.test(s) ? Number.parseInt(s, 10) : Number.NaN, + ); if ( indices.some( (n) => !Number.isInteger(n) || n < 1 || n > field.choices.length, @@ -118,6 +123,12 @@ async function promptField( ); continue; } + if (!multi && indices.length !== 1) { + // "1,2" on a single-select would silently drop everything after the + // first choice — re-prompt instead. + process.stderr.write(style.red(" Enter exactly one number.\n")); + continue; + } const values = indices.map((n) => field.choices[n - 1]!.value); if (multi) { const m = field as Extract; diff --git a/clients/daemon-cli/src/connection/stored-auth.ts b/clients/daemon-cli/src/connection/stored-auth.ts index 8046212757..f6fbce994d 100644 --- a/clients/daemon-cli/src/connection/stored-auth.ts +++ b/clients/daemon-cli/src/connection/stored-auth.ts @@ -43,21 +43,18 @@ function tokenFlagsFromState(state: unknown): { tokens?: TokenBlob; byIssuer?: Record; }; - if (s.tokens?.access_token) { - return { - hasTokens: true, - hasRefreshToken: Boolean(s.tokens.refresh_token), - }; - } + // Aggregate across every token slot: with multiple issuers, returning at + // the first access-token-bearing slot would make hasRefreshToken depend on + // object insertion order. + let hasTokens = Boolean(s.tokens?.access_token); + let hasRefreshToken = hasTokens && Boolean(s.tokens?.refresh_token); for (const slot of Object.values(s.byIssuer ?? {})) { if (slot?.tokens?.access_token) { - return { - hasTokens: true, - hasRefreshToken: Boolean(slot.tokens.refresh_token), - }; + hasTokens = true; + if (slot.tokens.refresh_token) hasRefreshToken = true; } } - return { hasTokens: false, hasRefreshToken: false }; + return { hasTokens, hasRefreshToken }; } async function readServersMap( diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 8acfe16b16..7fdb8d48c0 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -57,9 +57,11 @@ export type DaemonRequest = { id: string; op: DaemonOp; /** - * IPC auth token. Required when the daemon was started with - * `MCP_INSPECTOR_DAEMON_TOKEN` set (private mode); omitted for the shared - * default daemon. + * IPC auth token. Every daemon requires one: private mode passes it via + * `MCP_INSPECTOR_DAEMON_TOKEN`, and the shared default daemon generates + * one at startup and publishes it to `daemon.token` for clients to read. + * Optional only at the wire/type boundary so a request missing the token + * can still be parsed — and then rejected — rather than failing framing. */ token?: string; params?: diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 558b403b26..1c617cb050 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -80,6 +80,11 @@ export class DaemonServer { private server: net.Server | null = null; private readonly onShutdown: (() => void) | null; private stopping = false; + /** In-flight stop, memoized so a repeated stop (e.g. a second SIGINT) + * awaits the original cleanup instead of resolving immediately and letting + * its caller `process.exit()` mid-teardown, stranding the socket, token, + * and lock on disk. */ + private stopPromise: Promise | null = null; constructor(options: DaemonServerOptions = {}) { this.dir = options.dir ?? getDaemonDir(); @@ -150,9 +155,13 @@ export class DaemonServer { } } - async stop(reason: "idle" | "stop" | "signal" = "stop"): Promise { + stop(reason: "idle" | "stop" | "signal" = "stop"): Promise { + this.stopPromise ??= this.doStop(reason); + return this.stopPromise; + } + + private async doStop(reason: "idle" | "stop" | "signal"): Promise { void reason; - if (this.stopping) return; this.stopping = true; await this.registry.disconnectAll(); await new Promise((resolve) => { From 6e15ed78239945b6caa15f66794638d615a5b099 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 17:20:03 -0700 Subject: [PATCH 16/69] fix: address Copilot review round 7 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon ensure.ts: recreate daemon.log exclusively (rm + open "ax", 0600) — append-open followed symlinks and applied the mode only on create, so a pre-existing log could stay 0644 or redirect stderr. - daemon server.ts: publish daemon.token exclusively (rm + writeFileSync flag "wx", 0600) — writeFileSync followed a planted symlink surviving the parent-dir tightening, leaking the token to an attacker-readable target. - scripts/dependency-refresh.mjs: enroll clients/daemon-cli in INSTALLS so the monthly sweep covers its client-only devDependencies; test asserts the full enrollment list. - .claude/skills: local-dev (five-client install cascade, build chain, build:daemon-cli, mcpdo run line, core/dep-lockstep counts), testing (daemon-cli test placement, run command, coverage scope, description), project-structure (daemon-cli in the tree). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/skills/local-dev/SKILL.md | 49 ++++++++------- .claude/skills/project-structure/SKILL.md | 75 ++++++++++++----------- .claude/skills/testing/SKILL.md | 75 +++++++++++------------ clients/daemon-cli/src/daemon/ensure.ts | 8 ++- clients/daemon-cli/src/daemon/server.ts | 7 +++ scripts/dependency-refresh.mjs | 1 + scripts/dependency-refresh.test.mjs | 16 +++++ 7 files changed, 131 insertions(+), 100 deletions(-) diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md index 5236cb2a05..c2ce589317 100644 --- a/.claude/skills/local-dev/SKILL.md +++ b/.claude/skills/local-dev/SKILL.md @@ -21,8 +21,8 @@ npm install # at the REPO ROOT v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules`. A single root `npm install` is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades -`npm install` into `clients/web`, `clients/cli`, `clients/tui`, and -`clients/launcher`. +`npm install` into `clients/web`, `clients/cli`, `clients/daemon-cli`, +`clients/tui`, and `clients/launcher`. - **Fresh clone:** `npm install` at the root. - **After a pull that changes a client's dependencies:** re-run `npm install` at @@ -52,21 +52,24 @@ The launcher-driven scripts run the **built** launcher, so `npm run build` first: ```sh -npm run build # web → cli → tui → launcher +npm run build # web → cli → daemon-cli → tui → launcher npm run web # prod web launcher against clients/web/dist npm run web:dev # web launcher in --dev mode (Vite) ``` -Individual builds: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The +Individual builds: `build:web`, `build:cli`, `build:daemon-cli`, `build:tui`, +`build:launcher`. The web build produces both the browser SPA (`clients/web/dist`, Vite) and the Node prod-server runner (`clients/web/build`, tsup). To run the CLI or TUI: `node clients/launcher/build/index.js --cli …` / -`--tui …`. +`--tui …`. The connection CLI (`mcpdo`) has its own bin: +`node clients/daemon-cli/build/mcp-bin.js …` (or `npm link` from +`clients/daemon-cli` for a global `mcpdo`). ## The `@inspector/core` alias -`core/` holds the logic shared by all three clients and intentionally has **no +`core/` holds the logic shared by all five clients and intentionally has **no `package.json`** — it is not published on its own. Each client bundles it via a build-time alias: @@ -82,7 +85,7 @@ build-time alias: ## Where a dependency goes **The rules are in [`AGENTS.md`](../../../AGENTS.md) → Dependency placement, and -they are not restated here.** Read them there and come back for the *why* — what +they are not restated here.** Read them there and come back for the _why_ — what each rule is defending against, what it looked like when it was violated, and how to tell you have hit one. @@ -107,8 +110,8 @@ Keep two distinctions straight, because AGENTS.md's rules split on them: - **Root-declared is not the same as `core/`-imported.** `commander`, `open` and `@hono/node-server` are root `dependencies` too, but they are reached only - from client code. Only the `core/` set has to appear in *all three* bundler - `external` lists. + from client code. Only the `core/` set has to appear in _every_ client's + bundler `external` list. - **Root-declared is not the same as aliased.** The `vitest.shared.mts` pins and the `clients/web/tsconfig.*.json` `paths` cover the packages whose resolution is genuinely ambiguous, which is two different situations: the importer is @@ -163,25 +166,25 @@ do not need to be: `npm run` prepends **every ancestor** `node_modules/.bin` to all still resolves the root's copy. `clients/launcher` declares no `devDependencies` whatsoever and its `validate` is unchanged. -What a per-client declaration *does* buy is a second copy free to drift, and it +What a per-client declaration _does_ buy is a second copy free to drift, and it had (#2196): `globals` sat at `^17.7.0` at the root against `^17.4.0` in all four clients, and `typescript-eslint` at `^8.65.0` against `^8.56.1`. Nothing failed — which is the point. A lint or format tool that differs per client makes the gate's -verdict a function of *where you ran it*, and the exact `prettier` pin (#1790) +verdict a function of _where you ran it_, and the exact `prettier` pin (#1790) only means something when there is one of it. ⚠️ The line is **used by every client**, not "used by one" and not "is it toolchain". `tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node` and each client's own `@types/*` are toolchain too and stay where -they are — hoisting them would make every client install the union of all four. +they are — hoisting them would make every client install the union of all five. So do the ones **more than one** client declares without all of them doing so: `tsup` sits in web, cli and tui, and `vite` in web and tui on top of the root -*runtime* `dependency` that `--web --dev` needs. Neither is in scope here; +_runtime_ `dependency` that `--web --dev` needs. Neither is in scope here; whether to consolidate them is a separate call with a separate rationale (`vite` especially, since its root declaration is a `dependency`, not a `devDependency`). -#### What the walk-up does *not* buy you +#### What the walk-up does _not_ buy you ⚠️ **Deleting a client's declaration does not always delete the copy** — and where a copy survives, it is the one that wins. Two mechanisms put one back, @@ -195,7 +198,7 @@ neither of which the manifest mentions: - **A hoisted transitive.** `@types/express` brings `@types/node` into web and cli's trees on its own. -Those copies sit *nearer* than the root's, so `clients/web/node_modules/.bin` +Those copies sit _nearer_ than the root's, so `clients/web/node_modules/.bin` precedes the root bin directory on `PATH` and TypeScript resolves the nearest `node_modules/@types`. Verify with `npm exec -- which eslint` from the client rather than assuming — the assumption is what made the first cut of #2196 claim @@ -217,10 +220,10 @@ on disk. The two mechanisms are **not** equally safe, and neither is a guarantee #2226. ✅ **`verify:dep-lockstep` gates both of those since #2226.** Its second tier -compares every package **any** install *declares* — `dependencies`, +compares every package **any** install _declares_ — `dependencies`, `devDependencies` and `optionalDependencies`, unioned across the root and all -four clients — against every **top-level** copy in every install, independent of -what a `tsc` program resolves. So a tool *binary* that no program loads +five clients — against every **top-level** copy in every install, independent of +what a `tsc` program resolves. So a tool _binary_ that no program loads (`eslint`, `typescript`, `vitest`) and a transitive copy that no single program meets (the cli `@types/node` above) are both in scope now, as is a skew between two **clients** with no root copy involved (`@types/react`, web against tui). @@ -270,7 +273,7 @@ Two live examples worth knowing: tsup and Vite externalise what the **client's** `package.json` declares, and a root-only dependency is in none of them — so it is **bundled**, silently. For a CJS package inlined into an ESM bundle that is fatal: esbuild leaves a -`Dynamic require of "path" is not supported` shim that throws at *import* time, +`Dynamic require of "path" is not supported` shim that throws at _import_ time, so the binary dies before it parses a flag (`proper-lockfile`, #2082). `undici` (#2067) is the worse variant, because it is `import()`ed lazily: the @@ -293,7 +296,7 @@ file. ### Why React-rendering packages are the exception An externalised package resolves its own `react` from wherever npm placed -**it** — beside a React satisfying *that package's* peer range, which is looser +**it** — beside a React satisfying _that package's_ peer range, which is looser than ours in every case here. `ink-form` and `ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them and hoists them while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on @@ -303,8 +306,8 @@ another, and the TUI crashes on the first hook (#1952). a `createRequire` banner). ⚠️ **Never justify that exemption by a peer range** — it briefly read "its `">=19"` peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests -underneath. What makes it safe is the *root `react` range staying open to the -whole major*, so npm can dedupe. `clients/tui/__tests__/tsupConfig.test.ts` +underneath. What makes it safe is the _root `react` range staying open to the +whole major_, so npm can dedupe. `clients/tui/__tests__/tsupConfig.test.ts` enforces the whole split, the exemption included. ### Why a version skew is worth aligning rather than working around @@ -332,7 +335,7 @@ an `overrides` entry in that install (see the next section). ### Why `overrides` beats `npm audit fix` `tsup@8.5.1` declares `esbuild: ^0.27.0`, and the advisory covers -`0.27.3 - 0.28.0` with `0.27.7` the last 0.27.x — so there is no *upward* escape +`0.27.3 - 0.28.0` with `0.27.7` the last 0.27.x — so there is no _upward_ escape inside that range, and `npm audit fix` "resolves" it by silently **downgrading** to `0.27.2` across three installs (~700 lines of lockfile churn for a low-severity dev-only advisory; tried and reverted in #2058). The override forces one deduped diff --git a/.claude/skills/project-structure/SKILL.md b/.claude/skills/project-structure/SKILL.md index c7e0e54bce..3ec09ba2e3 100644 --- a/.claude/skills/project-structure/SKILL.md +++ b/.claude/skills/project-structure/SKILL.md @@ -20,6 +20,7 @@ inspector/ │ │ ├── server/ Node-only dev/prod backend wiring (see below) │ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab │ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias) +│ ├── daemon-cli/ The `mcpdo` connection CLI bin; daemon + client over a Unix socket (tsup bundle, @inspector/core alias) │ ├── tui/ Ink + React terminal UI (tsup bundle) │ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process ├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json) @@ -35,20 +36,20 @@ inspector/ Its entry point is the **`InspectorClient`** class, which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores. -| Directory | Owns | -| --- | --- | -| `core/mcp/` | `InspectorClient`, transports, state stores, config import, URI templates, task/subscription/App-elicitation protocol helpers | -| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch) | -| `core/mcp/remote/` | Browser HTTP/SSE transport + remote logger/fetch, and (under `node/`) the Hono backend it talks to | -| `core/mcp/state/` | The stores `core/react/` hooks read | -| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends | -| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy | -| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner | -| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint | -| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters | -| `core/node/` | Node-only helpers: version reader, host normalization/detection | -| `core/storage/` | File I/O helpers used by the OAuth persist backends | -| `core/logging/` | Silent pino logger singleton | +| Directory | Owns | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core/mcp/` | `InspectorClient`, transports, state stores, config import, URI templates, task/subscription/App-elicitation protocol helpers | +| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch) | +| `core/mcp/remote/` | Browser HTTP/SSE transport + remote logger/fetch, and (under `node/`) the Hono backend it talks to | +| `core/mcp/state/` | The stores `core/react/` hooks read | +| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends | +| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy | +| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner | +| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint | +| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters | +| `core/node/` | Node-only helpers: version reader, host normalization/detection | +| `core/storage/` | File I/O helpers used by the OAuth persist backends | +| `core/logging/` | Silent pino logger singleton | `core/` is isomorphic (browser + Node) and has **no `package.json`** — it is not published on its own. Its tests live in `clients/web/src/test/core/`, and its @@ -56,20 +57,20 @@ browser-consumed runtime is inside the web coverage gate. ## `clients/web/server/` — the Node backend -| File | Role | -| --- | --- | -| `vite-hono-plugin.ts` | Hono middleware on the Vite dev server | -| `server.ts` | Standalone Hono prod server | -| `start-vite-dev-server.ts` | In-process Vite starter for the launcher | -| `web-server-config.ts` | Env parsing, initial-config payload, startup banner | -| `sandbox-controller.ts` | The MCP Apps sandbox HTTP server | -| `app-origin-controller.ts` | The dedicated app origin for `_meta.ui.domain` | -| `inject-auth-token.ts` | Embeds the API token into served `index.html` | -| `resolve-bind-host.ts` | Bind-host policy (defaults to `127.0.0.1`; refuses a wildcard bind without the opt-in) | -| `browser-externalized-builtin-gate.ts` | Fails `vite build` on a browser-externalized Node built-in | -| `ensure-web-build.ts` | Builds `clients/web/dist` on demand for prod `--web` | - -Each of these files carries a header comment explaining the *why*; read the +| File | Role | +| -------------------------------------- | -------------------------------------------------------------------------------------- | +| `vite-hono-plugin.ts` | Hono middleware on the Vite dev server | +| `server.ts` | Standalone Hono prod server | +| `start-vite-dev-server.ts` | In-process Vite starter for the launcher | +| `web-server-config.ts` | Env parsing, initial-config payload, startup banner | +| `sandbox-controller.ts` | The MCP Apps sandbox HTTP server | +| `app-origin-controller.ts` | The dedicated app origin for `_meta.ui.domain` | +| `inject-auth-token.ts` | Embeds the API token into served `index.html` | +| `resolve-bind-host.ts` | Bind-host policy (defaults to `127.0.0.1`; refuses a wildcard bind without the opt-in) | +| `browser-externalized-builtin-gate.ts` | Fails `vite build` on a browser-externalized Node built-in | +| `ensure-web-build.ts` | Builds `clients/web/dist` on demand for prod `--web` | + +Each of these files carries a header comment explaining the _why_; read the source rather than looking for a second copy of it here. ## Web source layout: `src/lib` vs `src/utils` @@ -93,14 +94,14 @@ in near the top of the tree. Element components live in ## Where to put a new file -| It is… | It goes in | -| --- | --- | -| Logic two or more clients need | `core//` | -| Browser-only React or DOM code | `clients/web/src/` | -| A pure transform used by web | `clients/web/src/utils/` | -| A stateful adapter / subsystem wrapper used by web | `clients/web/src/lib/` | -| Node-only web backend wiring | `clients/web/server/` | -| A build/verify script | `scripts/` (with a sibling `*.test.mjs` if it has pure logic) | -| A test fixture MCP server | `test-servers/src/` + a config in `test-servers/configs/` | +| It is… | It goes in | +| -------------------------------------------------- | ------------------------------------------------------------- | +| Logic two or more clients need | `core//` | +| Browser-only React or DOM code | `clients/web/src/` | +| A pure transform used by web | `clients/web/src/utils/` | +| A stateful adapter / subsystem wrapper used by web | `clients/web/src/lib/` | +| Node-only web backend wiring | `clients/web/server/` | +| A build/verify script | `scripts/` (with a sibling `*.test.mjs` if it has pure logic) | +| A test fixture MCP server | `test-servers/src/` + a config in `test-servers/configs/` | Test placement is a separate question with its own rules — see `/testing`. diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 59604fd620..074f817c03 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -1,6 +1,6 @@ --- name: testing -description: Run, place and fix tests in this repo. Use when choosing which npm command runs a given suite (web unit, web integration, Storybook, cli, tui, launcher, scripts); when deciding where a new test file belongs — beside its source, under src/test/, or in a client's __tests__/; when a per-file coverage check fails or a v8 ignore is in question; when asking which test tier spawns the built binary rather than importing it; or when rendering, mounting or asserting on Mantine components and their transitions in a test. +description: Run, place and fix tests in this repo. Use when choosing which npm command runs a given suite (web unit, web integration, Storybook, cli, daemon-cli, tui, launcher, scripts); when deciding where a new test file belongs — beside its source, under src/test/, or in a client's __tests__/; when a per-file coverage check fails or a v8 ignore is in question; when asking which test tier spawns the built binary rather than importing it; or when rendering, mounting or asserting on Mantine components and their transitions in a test. disable-model-invocation: false --- @@ -20,7 +20,7 @@ choosing a location or writing a line.** end-to-end or integration coverage of an MCP operation — listing tools, paginating a list, calling a tool, reading a resource — almost always stands a fixture up, so treat that phrasing as the answer to the question above and load -`test-servers` *first*. Grepping for an existing test to copy is not a +`test-servers` _first_. Grepping for an existing test to copy is not a substitute: the fixture you find that way (a config under `test-servers/configs/`) does not tell you which of the three shapes below drives it, or that it can be stale. If the skill then shows the case needs no @@ -45,7 +45,7 @@ ways to depend on one, and they need different halves of that skill: config and no era table apply.** - **An integration or CLI test where stdio is the point → spawned stdio.** `getTestMcpServerCommand()` handed to a stdio transport or to the built CLI, - which spawns it. A subprocess *is* started, but it runs the stdio fixture's + which spawns it. A subprocess _is_ started, but it runs the stdio fixture's **default** config, so there is still nothing to pick — and nothing to override, so if the case needs a specific tool set it is an in-process HTTP test instead. @@ -64,31 +64,32 @@ ways to depend on one, and they need different halves of that skill: What applies to all three is that section's build warning. ⚠️ **Connecting is a strong hint, not the rule.** A few integration tests deliberately hand-roll a JSON-RPC server because the composable fixture - *cannot* produce what they assert on — `inspectorClient-malformed-list.test.ts` + _cannot_ produce what they assert on — `inspectorClient-malformed-list.test.ts` and `listSalvage-era.test.ts` need wire shapes the SDK's own server refuses to emit. Real transport, real client, no `test-servers/` dependency. Check whether a fixture can express the case before reaching for one. + - **It names or runs the built fixture without connecting.** `smoke:tui` boots - the TUI against a catalog whose stdio command *is* the built fixture, then + the TUI against a catalog whose stdio command _is_ the built fixture, then asserts it survives. No transport is driven and no protocol era applies, but the **build and staleness** half lands on it in full. ⚠️ **"A build ran" is not the dependency — using the artefact is.** -`clients/web`'s `pretest` runs `test-servers:build` before *every* unit run, so +`clients/web`'s `pretest` runs `test-servers:build` before _every_ unit run, so the fixture is on disk for tests that never reference it. What counts is whether the test **starts, spawns, configures, or hands a built entry to the subject under test**. That last clause is what covers `smoke:tui`, which drives no transport at all and still depends on the fixture — see the build-only bullet above. -⚠️ **And *importing* the package is not the dependency either.** The barrel +⚠️ **And _importing_ the package is not the dependency either.** The barrel exports plain functions as well as server factories, so a test can import from it and never stand a server up — `src/test/core/mcp/test-server-scope.test.ts` imports `createScopeCheckMiddleware` and friends to unit-test the scope middleware as a pure function, with no `start()` anywhere in the file. None of the procedure applies to it — no config, no era, no lifecycle — it is an ordinary unit test that happens to import its subject from that package. Ask -whether a *server* runs, not whether the import line is present. +whether a _server_ runs, not whether the import line is present. So the condition does **not** hold when the test renders a component from fixture props, exercises a pure function or a parser, or is a smoke that touches @@ -100,7 +101,7 @@ holds `storage/store-id.test.ts`, which validates a string, and `mcp/import/*`, which parses config files, right beside the tests that drive a live connection. They sit there for the node env and the 30s timeout, not because they connect — placement is the project manifest, so it cannot also be the fixture trigger. -Ask what the test *does*, not where it lives. +Ask what the test _does_, not where it lives. **In the connecting case**, the test drives a **real server over a real transport, never a mock**, and picking the fixture, building it, and connecting @@ -122,7 +123,7 @@ the Node clients are different.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead is a bug. -`clients/web/src/test/` is for the three things that *cannot* be co-located: +`clients/web/src/test/` is for the three things that _cannot_ be co-located: 1. **Tests of the repo-root `core/` package** → `src/test/core/…`, mirroring the `core/` folder layout. `core/` physically lives outside `clients/web/`, is @@ -132,7 +133,7 @@ web-owned test living under `src/test/` instead is a bug. `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). **Placement is the manifest** — any file under that folder is picked up by the integration project (node env, 30s timeouts) via a - folder glob; there is no enumeration to keep in sync. ⚠️ Placement is *not* + folder glob; there is no enumeration to keep in sync. ⚠️ Placement is _not_ the fixture trigger, though — this folder holds pure parser and storage tests alongside the connecting ones. If the test you are adding here **needs a fixture from `test-servers/`, load that skill first**; the fixture is half of @@ -141,7 +142,7 @@ web-owned test living under `src/test/` instead is a bug. 3. **Shared test infrastructure** — `renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`. -### `clients/cli`, `clients/tui`, `clients/launcher` — a top-level `__tests__/` +### `clients/cli`, `clients/daemon-cli`, `clients/tui`, `clients/launcher` — a top-level `__tests__/` **All** their tests, not beside their source. Their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*`, so a @@ -157,17 +158,18 @@ file its glob misses and still exits 0. ## Running them -| Scope | From | Command | -| --- | --- | --- | -| Web unit | `clients/web` | `npm run test` (`test:watch` while iterating) | -| Web integration | `clients/web` | `npm run test:integration` | -| Web Storybook play fns | `clients/web` | `npm run test:storybook` | -| CLI | `clients/cli` | `npm run test` (`pretest` builds test-servers + the bin) | -| TUI | `clients/tui` | `npm run test` | -| Launcher | `clients/launcher` | `npm run test` | -| Root tooling | repo root | `npm run test:scripts` | -| Everything, fast | repo root | `npm run validate` | -| The coverage gate | repo root | `npm run coverage` | +| Scope | From | Command | +| ---------------------- | -------------------- | -------------------------------------------------------- | +| Web unit | `clients/web` | `npm run test` (`test:watch` while iterating) | +| Web integration | `clients/web` | `npm run test:integration` | +| Web Storybook play fns | `clients/web` | `npm run test:storybook` | +| CLI | `clients/cli` | `npm run test` (`pretest` builds test-servers + the bin) | +| Connection CLI (mcpdo) | `clients/daemon-cli` | `npm run test` (`pretest` builds test-servers + the bin) | +| TUI | `clients/tui` | `npm run test` | +| Launcher | `clients/launcher` | `npm run test` | +| Root tooling | repo root | `npm run test:scripts` | +| Everything, fast | repo root | `npm run validate` | +| The coverage gate | repo root | `npm run coverage` | There is **no aggregate root `test` script** — each client self-validates. @@ -201,8 +203,8 @@ inside the `coverage` gate. CI therefore has no separate `test:integration` step ## The coverage gate -**Per-file ≥90 on all four dimensions**, CI-enforced, across web, cli, tui and -launcher. New code must clear 90 on every dimension. +**Per-file ≥90 on all four dimensions**, CI-enforced, across web, cli, +daemon-cli, tui and launcher. New code must clear 90 on every dimension. Scope notes: @@ -220,19 +222,14 @@ Scope notes: only exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **TUI** covers **all of `src/**`, React surface included**. Components mount - through `__tests__/helpers/renderTui.tsx` — `ink-testing-library`'s `render` - with every frame ANSI-stripped — alongside the passthrough doubles in the same - directory; keypresses are driven through stdin. The only exclusion is - `src/tui-servers.ts` (a pure re-export, excluded so it doesn't surface as a - misleading 0/0 row). - ⚠️ **Import `render` from that helper, not from `ink-testing-library`.** Ink - writes styling *inside* the styled run, so `Info` - reaches the frame buffer with escapes between `I` and `nfo` and a plain - `toContain("Info")` fails against a component that is rendering correctly. It - only shows up where chalk emits color — a developer whose shell exports - `FORCE_COLOR` — so CI, which has no TTY, stays green on a suite that is red - for them (#2207). If a frame assertion fails on a string you can plainly see - in the printed diff, that is the tell. Reach `stdout.lastFrame()` on the +through `**tests**/helpers/renderTui.tsx`—`ink-testing-library`'s `render`with every frame ANSI-stripped — alongside the passthrough doubles in the same +directory; keypresses are driven through stdin. The only exclusion is`src/tui-servers.ts`(a pure re-export, excluded so it doesn't surface as a +misleading 0/0 row). +⚠️ **Import`render`from that helper, not from`ink-testing-library`.** Ink +writes styling *inside* the styled run, so `Info`reaches the frame buffer with escapes between`I`and`nfo`and a plain`toContain("Info")`fails against a component that is rendering correctly. It +only shows up where chalk emits color — a developer whose shell exports`FORCE_COLOR`— so CI, which has no TTY, stays green on a suite that is red +for them (#2207). If a frame assertion fails on a string you can plainly see +in the printed diff, that is the tell. Reach`stdout.lastFrame()` on the returned instance for the raw bytes. ### When a `v8 ignore` is justified @@ -295,7 +292,7 @@ the skill and use all of it**: which showcase config covers the feature, which protocol era to connect with, how to add a combination that does not exist yet, and why a fixture can keep serving stale code after an edit. -**A test that only *names* the built fixture needs that skill too, for a +**A test that only _names_ the built fixture needs that skill too, for a narrower reason.** `smoke:tui` boots the TUI against a catalog whose stdio command is the build output and asserts it survives — it opens no transport, so config choice and protocol era do not apply to it, but **building the fixture diff --git a/clients/daemon-cli/src/daemon/ensure.ts b/clients/daemon-cli/src/daemon/ensure.ts index 1b4036cfd5..a5bd827d84 100644 --- a/clients/daemon-cli/src/daemon/ensure.ts +++ b/clients/daemon-cli/src/daemon/ensure.ts @@ -186,7 +186,13 @@ export async function ensureDaemon(options?: { const logPath = getDaemonLogPath(dir); let stderrTarget: number | "ignore" = "ignore"; try { - stderrTarget = fs.openSync(logPath, "a", 0o600); + // Recreate exclusively: append-open follows symlinks and applies the mode + // only on create, so a pre-existing daemon.log could stay group/other- + // readable or redirect daemon stderr to a planted target. The parent dir + // was just tightened to 0700; removing the entry closes the window for + // children planted before that. + fs.rmSync(logPath, { force: true }); + stderrTarget = fs.openSync(logPath, "ax", 0o600); /* v8 ignore next 3 -- log capture is best-effort; openSync on a freshly ensured 0700 dir cannot be made to fail portably in tests. */ } catch { diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 1c617cb050..ee88fbdab0 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -110,8 +110,15 @@ export class DaemonServer { // read the token it needs. See getDaemonTokenPath. if (this.requiredToken !== undefined) { const tokenPath = getDaemonTokenPath(this.dir); + // Exclusive create after removing any existing entry: writeFileSync + // follows symlinks, and ensureDaemonDir's tightening of the parent + // does not remove children planted while the dir was writable — a + // planted symlink would leak the token into an attacker-readable + // file. rmSync removes a symlink itself, never its target. + fs.rmSync(tokenPath, { force: true }); fs.writeFileSync(tokenPath, this.requiredToken + "\n", { mode: 0o600, + flag: "wx", }); try { fs.chmodSync(tokenPath, 0o600); diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs index 348e423672..01e6d22fe9 100644 --- a/scripts/dependency-refresh.mjs +++ b/scripts/dependency-refresh.mjs @@ -52,6 +52,7 @@ export const INSTALLS = [ { dir: "clients/cli", label: "clients/cli" }, { dir: "clients/tui", label: "clients/tui" }, { dir: "clients/launcher", label: "clients/launcher" }, + { dir: "clients/daemon-cli", label: "clients/daemon-cli" }, ]; /** Where the `uses:` refs this sweep checks live, relative to the repo root. */ diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs index bbf407e889..3a01f41e9e 100644 --- a/scripts/dependency-refresh.test.mjs +++ b/scripts/dependency-refresh.test.mjs @@ -263,6 +263,22 @@ test("main throws when npm outdated exits with an undocumented status", () => { assert.equal(ghCall(spawn, "create"), undefined); }); +test("INSTALLS enrolls the root and every client install", () => { + // A client absent here is silently skipped by the monthly sweep — its + // client-only devDependencies would never show up in `npm outdated`. + assert.deepEqual( + INSTALLS.map((i) => i.dir), + [ + ".", + "clients/web", + "clients/cli", + "clients/tui", + "clients/launcher", + "clients/daemon-cli", + ], + ); +}); + test("main sweeps every install and files one milestoned issue", () => { const spawn = fakeSpawn({ outdated: { From 6c5aef288d08802b27bafe053e9e49ae55ea1094 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 18:45:14 -0700 Subject: [PATCH 17/69] fix(daemon-cli): address Copilot review round 8 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon/server.ts: quiesce shutdown — reject new ops while stopping (status ops stay answerable), wait (bounded 3s) for in-flight ops so a racing connect can't register a live client after disconnectAll's snapshot, track accepted IPC sockets and destroySoon them so server.close() doesn't hang on long-lived streams - daemon/elicitation-bridge.ts: per-client registry with a single listener so concurrent RPCs on one connection get exactly-once elicitation delivery (oldest active caller) instead of duplicate prompts and double respond(); events with no remaining caller are cancelled - connection/sanitize.ts: sanitizeDeep builds null-prototype objects so a literal "__proto__" JSON key is preserved instead of silently dropped - connection/mcp.ts: always resolve stdio cwd against the caller's working directory, so a relative configured cwd doesn't resolve against the daemon's own cwd - connection/resolve-command.ts: on win32, try an already-suffixed command name as-is before appending PATHEXT extensions (cmd.exe-like) - tests: __proto__ preservation, stopping-op rejection, quiesce ordering, exactly-once/queued-cancel bridge delivery Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-coverage.test.ts | 60 +++++++++++++ .../__tests__/elicitation-bridge.test.ts | 57 +++++++++++++ clients/daemon-cli/__tests__/sanitize.test.ts | 22 +++++ clients/daemon-cli/src/connection/mcp.ts | 11 ++- .../src/connection/resolve-command.ts | 10 ++- clients/daemon-cli/src/connection/sanitize.ts | 8 +- .../src/daemon/elicitation-bridge.ts | 84 ++++++++++++++----- clients/daemon-cli/src/daemon/server.ts | 64 +++++++++++++- 8 files changed, 287 insertions(+), 29 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 76cfb97990..783fcbd954 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -321,6 +321,66 @@ describe("daemon coverage", () => { server = undefined; }); + it("rejects new ops while stopping but keeps status ops answerable", async () => { + server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + vi.spyOn(server.registry, "disconnectAll").mockImplementation(() => gate); + const stopP = server.stop("stop"); + + const rejected = await server.handle({ id: "q1", op: "connections/list" }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error.code).toBe("daemon_stopping"); + + const status = await server.handle({ id: "q2", op: "daemon/status" }); + expect(status.ok).toBe(true); + const pong = await server.handle({ id: "q3", op: "ping" }); + expect(pong.ok).toBe(true); + + release(); + await stopP; + server = undefined; + }); + + it("stop() quiesces in-flight ops before disconnecting connections", async () => { + // A connect racing shutdown used to register its client *after* + // disconnectAll's snapshot, leaking a live child process. Shutdown now + // waits for in-flight ops so the late registration is included. + server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + const order: string[] = []; + let releaseConnect!: () => void; + const gate = new Promise((resolve) => (releaseConnect = resolve)); + vi.spyOn(server.registry, "connect").mockImplementation(async () => { + order.push("connect:start"); + await gate; + order.push("connect:end"); + return { name: "a" } as never; + }); + vi.spyOn(server.registry, "disconnectAll").mockImplementation(async () => { + order.push("disconnectAll"); + }); + + const opP = server.handle({ + id: "c1", + op: "connect", + params: { + name: "a", + serverConfig: { type: "stdio", command: "x" }, + serverIdentity: "x", + } as never, + }); + await vi.waitFor(() => expect(order).toContain("connect:start")); + const stopP = server.stop("stop"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(order).toEqual(["connect:start"]); // stop is waiting, not tearing down + + releaseConnect(); + await opP; + await stopP; + expect(order).toEqual(["connect:start", "connect:end", "disconnectAll"]); + server = undefined; + }); + it("callDaemon times out a hung server", async () => { const d = freshDir(); const sock = path.join(d, "daemon.sock"); diff --git a/clients/daemon-cli/__tests__/elicitation-bridge.test.ts b/clients/daemon-cli/__tests__/elicitation-bridge.test.ts index 0e9b5ef4a9..03048d1786 100644 --- a/clients/daemon-cli/__tests__/elicitation-bridge.test.ts +++ b/clients/daemon-cli/__tests__/elicitation-bridge.test.ts @@ -173,6 +173,63 @@ describe("wireElicitationBridge", () => { unwire(); }); + it("delivers each elicitation to exactly one of two concurrent callers (oldest first)", async () => { + const { client, emit } = fakeClient(); + const answerFor = (frame: { + id: string; + elicitationId: string; + }): ElicitationResponseFrame => ({ + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + }); + const requestA = vi + .fn() + .mockImplementation(async (frame) => answerFor(frame)); + const requestB = vi + .fn() + .mockImplementation(async (frame) => answerFor(frame)); + const unwireA = wireElicitationBridge( + client, + { request: requestA }, + "req-a", + ); + const unwireB = wireElicitationBridge( + client, + { request: requestB }, + "req-b", + ); + + const first = fakeMessage({ id: "e1" }); + emit(first); + await vi.waitFor(() => expect(first.respond).toHaveBeenCalled()); + expect(requestA).toHaveBeenCalledTimes(1); + expect(requestB).not.toHaveBeenCalled(); + expect(first.respond).toHaveBeenCalledTimes(1); + + // Once the oldest caller settles, the next event goes to the survivor. + unwireA(); + const second = fakeMessage({ id: "e2" }); + emit(second); + await vi.waitFor(() => expect(second.respond).toHaveBeenCalled()); + expect(requestA).toHaveBeenCalledTimes(1); + expect(requestB).toHaveBeenCalledTimes(1); + unwireB(); + }); + + it("cancels an event already queued when every caller settled before dispatch", async () => { + const { client, emit } = fakeClient(); + const request = vi.fn(); + const unwire = wireElicitationBridge(client, { request }, "req-1"); + const message = fakeMessage(); + emit(message); + unwire(); // settle before the queued microtask dispatches + await vi.waitFor(() => expect(message.cancel).toHaveBeenCalled()); + expect(request).not.toHaveBeenCalled(); + expect(message.respond).not.toHaveBeenCalled(); + }); + it("unwire stops the listener from reacting to further events", () => { const { client, emit } = fakeClient(); const channel: ElicitationChannel = { request: vi.fn() }; diff --git a/clients/daemon-cli/__tests__/sanitize.test.ts b/clients/daemon-cli/__tests__/sanitize.test.ts index db5f83a37b..0ecdb24b13 100644 --- a/clients/daemon-cli/__tests__/sanitize.test.ts +++ b/clients/daemon-cli/__tests__/sanitize.test.ts @@ -77,6 +77,28 @@ describe("sanitizeDeep", () => { expect(input.text).toBe("esc\u001b"); expect(out.text).toBe("esc\u241b"); }); + + it('preserves a literal "__proto__" key instead of dropping it', () => { + // On a plain {} accumulator, assigning "__proto__" hits the prototype + // setter and silently discards the entry; the null-prototype result + // keeps it as an ordinary own property. + const input = JSON.parse( + '{"__proto__": {"polluted": "esc\\u001b"}, "a": 1}', + ); + const out = sanitizeDeep(input) as Record; + expect(Object.getOwnPropertyNames(out)).toContain("__proto__"); + expect( + ( + Object.getOwnPropertyDescriptor(out, "__proto__")?.value as Record< + string, + unknown + > + ).polluted, + ).toBe("esc\u241b"); + expect(out.a).toBe(1); + // No pollution of shared prototypes either. + expect(({} as Record).polluted).toBeUndefined(); + }); }); describe("isSafeLinkTarget", () => { diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index b53f97da36..af7fd76bb1 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -396,9 +396,14 @@ function registerConnect(program: CommandType): void { // relative paths against the DAEMON's cwd — whichever directory the // first mcpdo invocation happened to run from. Pin it to the caller's // cwd, which is what `mcpdo connect node ./server.js` means to the user. - // A cwd configured in the catalog/config entry (or --cwd) still wins. - if (serverConfig.type === "stdio" && !serverConfig.cwd) { - serverConfig = { ...serverConfig, cwd: process.cwd() }; + // A cwd configured in the catalog/config entry (or --cwd) still wins — + // but a *relative* configured cwd must also be resolved here, against + // this shell's cwd, not left for the daemon to resolve post-chdir. + if (serverConfig.type === "stdio") { + serverConfig = { + ...serverConfig, + cwd: path.resolve(serverConfig.cwd ?? process.cwd()), + }; } // Same staleness problem for bare command names: the daemon would look // `node` up in the PATH of whichever mcpdo invocation first spawned it. diff --git a/clients/daemon-cli/src/connection/resolve-command.ts b/clients/daemon-cli/src/connection/resolve-command.ts index ee8d633d3b..2731472c21 100644 --- a/clients/daemon-cli/src/connection/resolve-command.ts +++ b/clients/daemon-cli/src/connection/resolve-command.ts @@ -25,10 +25,16 @@ export function resolveCommandPath( return command; } const pathVar = env.PATH ?? ""; - /* v8 ignore next 4 -- platform-only branch: PATHEXT applies on win32 only */ + /* v8 ignore next 7 -- platform-only branch: PATHEXT applies on win32 only */ const extensions = process.platform === "win32" - ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") + ? // cmd.exe-like: an already-suffixed name ("node.exe") is tried as-is + // before PATHEXT variants — otherwise only "node.exe.EXE" etc. would + // be searched and resolution would silently fall to the daemon's PATH. + [ + ...(path.extname(command) ? [""] : []), + ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";"), + ] : [""]; for (const dir of pathVar.split(path.delimiter)) { // POSIX: an empty PATH entry means the current directory. Resolve it (and diff --git a/clients/daemon-cli/src/connection/sanitize.ts b/clients/daemon-cli/src/connection/sanitize.ts index 64e7ea546e..fcc3a1f936 100644 --- a/clients/daemon-cli/src/connection/sanitize.ts +++ b/clients/daemon-cli/src/connection/sanitize.ts @@ -41,7 +41,13 @@ export function sanitizeDeep(value: T): T { if (typeof value === "string") return sanitizeText(value) as T; if (Array.isArray(value)) return value.map((v) => sanitizeDeep(v)) as T; if (value !== null && typeof value === "object") { - const out: Record = {}; + // Null prototype: a JSON key named "__proto__" must become an own + // property, not invoke the inherited prototype setter (which would + // silently drop the field from formatted output). + const out: Record = Object.create(null) as Record< + string, + unknown + >; for (const [k, v] of Object.entries(value as Record)) { out[sanitizeText(k)] = sanitizeDeep(v); } diff --git a/clients/daemon-cli/src/daemon/elicitation-bridge.ts b/clients/daemon-cli/src/daemon/elicitation-bridge.ts index 6731bc1b66..3de2940ffa 100644 --- a/clients/daemon-cli/src/daemon/elicitation-bridge.ts +++ b/clients/daemon-cli/src/daemon/elicitation-bridge.ts @@ -14,11 +14,33 @@ import type { InspectorClientEventMap } from "@inspector/core/mcp/inspectorClien import type { ElicitationChannel } from "./ipc-glue.js"; import type { ElicitationRequestFrame } from "./protocol.js"; +/** + * Per-client bridge registry. Concurrent RPCs on the same connection would + * otherwise each install their own `newPendingElicitation` listener, so one + * server elicitation would be delivered to every active caller — duplicate + * prompts and multiple `respond()` calls. One listener per client dispatches + * each event to exactly one active subscriber. Core cannot attribute an + * elicitation to a specific in-flight call, so the oldest active subscriber + * is chosen (with core's one-pending-at-a-time guarantee the sets coincide + * for the common single-RPC case). + */ +type BridgeSubscriber = { channel: ElicitationChannel; requestId: string }; + +type BridgeRegistry = { + subscribers: BridgeSubscriber[]; + queue: Promise; + listener: ( + event: TypedEventGeneric, + ) => void; +}; + +const bridgeRegistries = new WeakMap(); + /** * Wires `client`'s pending-elicitation events to `channel` for the duration * of one in-flight call. Returns a cleanup function that must be called - * (typically in a `finally`) once the call settles, so the listener doesn't - * outlive the request. + * (typically in a `finally`) once the call settles, so the subscription + * doesn't outlive the request. * * Core resolves elicitations sequentially — never more than one pending at a * time (see `inspectorClient.ts`'s `fulfilInputRequests` and @@ -32,28 +54,46 @@ export function wireElicitationBridge( channel: ElicitationChannel, requestId: string, ): () => void { - let queue: Promise = Promise.resolve(); - - const onNewPendingElicitation = ( - event: TypedEventGeneric, - ) => { - const message = event.detail; - if (message.origin === "task-input-required") { - // Task-augmented — the originating call already returned; nothing here - // is awaiting this elicitation, so leave it pending for a future - // tasks/-based command to answer. - return; - } - queue = queue.then(() => handleOne(channel, requestId, message)); - }; - - client.addEventListener("newPendingElicitation", onNewPendingElicitation); + let registry = bridgeRegistries.get(client); + if (!registry) { + const created: BridgeRegistry = { + subscribers: [], + queue: Promise.resolve(), + listener: (event) => { + const message = event.detail; + if (message.origin === "task-input-required") { + // Task-augmented — the originating call already returned; nothing + // here is awaiting this elicitation, so leave it pending for a + // future tasks/-based command to answer. + return; + } + created.queue = created.queue.then(() => { + const subscriber = created.subscribers[0]; + if (!subscriber) { + // Every subscribing call settled before this event was + // dispatched — nothing is awaiting it, settle it like a channel + // failure would. + message.cancel(); + return; + } + return handleOne(subscriber.channel, subscriber.requestId, message); + }); + }, + }; + client.addEventListener("newPendingElicitation", created.listener); + bridgeRegistries.set(client, created); + registry = created; + } + const subscriber: BridgeSubscriber = { channel, requestId }; + registry.subscribers.push(subscriber); return () => { - client.removeEventListener( - "newPendingElicitation", - onNewPendingElicitation, - ); + const index = registry.subscribers.indexOf(subscriber); + if (index >= 0) registry.subscribers.splice(index, 1); + if (registry.subscribers.length === 0) { + client.removeEventListener("newPendingElicitation", registry.listener); + bridgeRegistries.delete(client); + } }; } diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index ee88fbdab0..b3a66adaad 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -85,6 +85,15 @@ export class DaemonServer { * its caller `process.exit()` mid-teardown, stranding the socket, token, * and lock on disk. */ private stopPromise: Promise | null = null; + /** In-flight handleOutcome calls; shutdown quiesces these before the + * registry snapshot so a concurrent connect cannot register a live client + * after disconnectAll and leak it. */ + private activeOps = 0; + private opsIdleResolvers: (() => void)[] = []; + /** Accepted IPC sockets. Long-lived stream sockets never end on their own, + * so shutdown flushes and destroys them — otherwise server.close() would + * wait forever. */ + private readonly ipcSockets = new Set(); constructor(options: DaemonServerOptions = {}) { this.dir = options.dir ?? getDaemonDir(); @@ -128,6 +137,8 @@ export class DaemonServer { } this.server = net.createServer((socket) => { + this.ipcSockets.add(socket); + socket.once("close", () => this.ipcSockets.delete(socket)); acceptDaemonConnection(socket, (req, elicitation) => this.handleOutcome(req, elicitation), ); @@ -170,7 +181,18 @@ export class DaemonServer { private async doStop(reason: "idle" | "stop" | "signal"): Promise { void reason; this.stopping = true; + // Quiesce: new ops are rejected above; wait (bounded — an rpc blocked on + // an interactive elicitation prompt must not hang shutdown forever) for + // in-flight ops so a concurrent connect lands in the registry before the + // disconnect snapshot below. + await this.waitForActiveOps(DaemonServer.QUIESCE_TIMEOUT_MS); await this.registry.disconnectAll(); + // Flush pending response writes (e.g. daemon/stop's own {stopping:true}) + // then drop the sockets: long-lived stream sockets never end on their + // own and would keep server.close() waiting forever. + for (const socket of [...this.ipcSockets]) { + socket.destroySoon(); + } await new Promise((resolve) => { if (!this.server) { resolve(); @@ -183,6 +205,22 @@ export class DaemonServer { this.onShutdown?.(); } + /** Grace period for in-flight ops during shutdown before teardown proceeds + * anyway. Exported for tests. */ + static readonly QUIESCE_TIMEOUT_MS = 3_000; + + private waitForActiveOps(timeoutMs: number): Promise { + if (this.activeOps === 0) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + this.opsIdleResolvers.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + status(): DaemonStatus { return { pid: process.pid, @@ -207,7 +245,15 @@ export class DaemonServer { ): Promise { try { assertDaemonToken(this.requiredToken, request.token); - return await this.dispatch(request, elicitation); + this.activeOps++; + try { + return await this.dispatch(request, elicitation); + } finally { + this.activeOps--; + if (this.activeOps === 0) { + for (const resolve of this.opsIdleResolvers.splice(0)) resolve(); + } + } } catch (error) { if (error instanceof CliExitCodeError) { return { @@ -242,6 +288,22 @@ export class DaemonServer { request: DaemonRequest, elicitation: ElicitationChannel, ): Promise { + // Once shutdown starts, new work is rejected: an op accepted here could + // otherwise register a live client after disconnectAll's snapshot. + // Status-style ops stay answerable; a repeated daemon/stop joins the + // in-flight stop via the memoized promise. + if ( + this.stopping && + request.op !== "ping" && + request.op !== "daemon/status" && + request.op !== "daemon/stop" + ) { + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Connection daemon is shutting down.", + { code: "daemon_stopping" }, + ); + } switch (request.op) { case "ping": return { From 9fba5979d9a7d3b098bba1e7acb7b6537c40e464 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 24 Sep 2026 23:50:27 -0700 Subject: [PATCH 18/69] fix(daemon-cli): address Copilot review round 9 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon/connections.ts: registry is shutdown-aware — disconnectAll() marks it closed; a connect still in flight when shutdown's bounded quiesce grace expires now tears its freshly connected client down and fails with daemon_stopping instead of registering a live transport after the snapshot (leaking it past daemon exit); post-close connects fail fast before dialing - daemon/client.ts + call sites: callDaemon timeoutMs 0 disables the client-side deadline; dispatch's rpc and mcp connect pass 0 since the daemon enforces the configured MCP request/connect timeouts (a fixed 60s local timer falsely failed --connect-timeout 0/>60s and long tool calls while the daemon kept executing them) - connection/parse-tool-args.ts: key:=value accumulator is null-prototype so a literal "__proto__" argument becomes an own property (matching the inline-JSON path) instead of vanishing into the prototype setter - tests: late-connect self-teardown + post-close fast-fail, no-deadline callDaemon (close still fails it), __proto__ own-property parse Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-connections.test.ts | 48 +++++++++++++++++++ .../__tests__/daemon-coverage.test.ts | 36 ++++++++++++++ .../__tests__/parse-tool-args.test.ts | 11 +++++ clients/daemon-cli/src/connection/dispatch.ts | 3 ++ clients/daemon-cli/src/connection/mcp.ts | 4 ++ .../src/connection/parse-tool-args.ts | 5 +- clients/daemon-cli/src/daemon/client.ts | 30 ++++++++---- clients/daemon-cli/src/daemon/connections.ts | 28 +++++++++++ 8 files changed, 155 insertions(+), 10 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index cc67fe8423..2a6a4f8974 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -293,6 +293,54 @@ describe("ConnectionRegistry", () => { } }); + it("a connect that outlives shutdown's quiesce grace tears its client down instead of leaking it", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + let releaseConnect!: () => void; + const gate = new Promise((resolve) => (releaseConnect = resolve)); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockImplementation(() => gate); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + try { + const params = { + name: "late", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + } as const; + const pending = registry.connect(params); + // Ensure the client is actually dialing before the shutdown snapshot + // runs — the bounded-quiesce-expired case (otherwise the entry check + // rejects it before a client exists). + await vi.waitFor(() => expect(connectSpy).toHaveBeenCalled()); + await registry.disconnectAll(); + releaseConnect(); + await expect(pending).rejects.toMatchObject({ + envelope: { code: "daemon_stopping" }, + }); + // The freshly connected client was disconnected, not registered. + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(registry.connectionCount()).toBe(0); + // And a connect arriving after close fails fast, before dialing. + await expect(registry.connect(params)).rejects.toMatchObject({ + envelope: { code: "daemon_stopping" }, + }); + expect(connectSpy).toHaveBeenCalledTimes(1); // no second dial + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const { NodeOAuthStorage, resetNodeOAuthStorageCache } = diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 783fcbd954..f5a5d1e20c 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -402,6 +402,42 @@ describe("daemon coverage", () => { } }, 5000); + it("callDaemon with timeoutMs 0 arms no local deadline; daemon close still fails it", async () => { + // rpc/connect callers pass 0 because the daemon enforces the configured + // MCP timeouts; a fixed 60s local timer falsely failed long tool calls. + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const sockets: net.Socket[] = []; + const silent = net.createServer((socket) => { + sockets.push(socket); + socket.on("error", () => {}); + }); + await new Promise((resolve) => silent.listen(sock, resolve)); + try { + const pending = callDaemon( + "ping", + {}, + { socketPath: sock, timeoutMs: 0 }, + ); + let settled = false; + // void: observer only; the promise itself is asserted on below. + void pending.catch(() => (settled = true)).then(() => (settled = true)); + // Longer than the "times out a hung server" test's deadline: nothing + // fires locally. + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(settled).toBe(false); + for (const socket of sockets) socket.destroy(); + await expect(pending).rejects.toThrow(/closed the connection/); + } finally { + silent.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }, 5000); + it("connections/use and reconnect replace an existing connection", async () => { const { command, args } = getTestMcpServerCommand(); const registry = new ConnectionRegistry(0); diff --git a/clients/daemon-cli/__tests__/parse-tool-args.test.ts b/clients/daemon-cli/__tests__/parse-tool-args.test.ts index b1bc380ac5..16529a0d85 100644 --- a/clients/daemon-cli/__tests__/parse-tool-args.test.ts +++ b/clients/daemon-cli/__tests__/parse-tool-args.test.ts @@ -23,6 +23,17 @@ describe("parseToolCallPositionals", () => { }); }); + it('keeps a literal "__proto__" key as an own property, matching the inline-JSON path', () => { + // On a plain {} accumulator this key would hit the prototype setter and + // vanish while remapping the accumulator's prototype. + const out = parseToolCallPositionals(['__proto__:={"polluted":true}']); + expect(Object.getOwnPropertyNames(out)).toContain("__proto__"); + expect(Object.getOwnPropertyDescriptor(out, "__proto__")?.value).toEqual({ + polluted: true, + }); + expect(({} as Record).polluted).toBeUndefined(); + }); + it("parses a single inline JSON object", () => { expect(parseToolCallPositionals(['{"message":"Foo","count":2}'])).toEqual({ message: "Foo", diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index d08a79e00b..8c6891f093 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -91,6 +91,9 @@ export async function dispatchConnectionRpc( try { outcome = await callDaemon("rpc", params, { socketPath, + // Core enforces the configured MCP request timeout daemon-side; a + // fixed local deadline would falsely fail long-running tool calls. + timeoutMs: 0, signal: ac.signal, onElicitation: (frame) => promptElicitation(frame, { diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index af7fd76bb1..796a332d60 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -448,6 +448,9 @@ function registerConnect(program: CommandType): void { try { result = await callDaemon("connect", connectParams, { socketPath, + // The daemon enforces the configured connect timeout (which may be + // 0 = unlimited or exceed 60s); no fixed local deadline. + timeoutMs: 0, }); } catch (error) { if ( @@ -471,6 +474,7 @@ function registerConnect(program: CommandType): void { const { socketPath: freshSocketPath } = await ensureDaemon(); result = await callDaemon("connect", connectParams, { socketPath: freshSocketPath, + timeoutMs: 0, }); } await writeConnectionOutput(outOpts(opts), { diff --git a/clients/daemon-cli/src/connection/parse-tool-args.ts b/clients/daemon-cli/src/connection/parse-tool-args.ts index cb447805d0..1c7829d587 100644 --- a/clients/daemon-cli/src/connection/parse-tool-args.ts +++ b/clients/daemon-cli/src/connection/parse-tool-args.ts @@ -36,7 +36,10 @@ export function parseToolCallPositionals( return parsed as Record; } - const out: Record = {}; + // Null prototype: a "__proto__" key must become an ordinary own property + // (as the inline-JSON path preserves it), not hit the {} prototype setter + // and vanish while remapping the accumulator's prototype. + const out: Record = Object.create(null); for (const pair of args) { const sep = pair.indexOf(":="); if (sep === -1) { diff --git a/clients/daemon-cli/src/daemon/client.ts b/clients/daemon-cli/src/daemon/client.ts index 8c734ff3c7..6ee05e2d29 100644 --- a/clients/daemon-cli/src/daemon/client.ts +++ b/clients/daemon-cli/src/daemon/client.ts @@ -17,6 +17,15 @@ import type { export type DaemonClientOptions = { socketPath?: string; /** Per-request timeout in ms. */ + /** + * Client-side deadline for the whole request; `0` disables it. Defaults to + * 60s, which suits short control ops (ping, status, list). Callers of ops + * whose duration is governed by configured MCP timeouts the daemon already + * enforces (`connect` honouring `--connect-timeout`, `rpc` honouring the + * request timeout — either may validly run past 60s or be unlimited) must + * pass `0` so the fixed local timer can't fail an op the daemon is still + * executing. Daemon death is still detected via socket error/close. + */ timeoutMs?: number; /** IPC token; defaults to `MCP_INSPECTOR_DAEMON_TOKEN` when set. */ token?: string; @@ -190,15 +199,18 @@ export async function callDaemon( } }); - timer = setTimeout(() => { - fail( - new CliExitCodeError( - EXIT_CODES.UNREACHABLE, - `Daemon request '${op}' timed out after ${timeoutMs}ms`, - { code: "daemon_timeout" }, - ), - ); - }, timeoutMs); + timer = + timeoutMs > 0 + ? setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon request '${op}' timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs) + : undefined; options.signal?.addEventListener("abort", onAbort, { once: true }); diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index c8d3653410..70cd6bbbe7 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -87,6 +87,14 @@ export class ConnectionRegistry { */ private readonly nameLocks = new Map>(); + /** + * Set by {@link disconnectAll} (daemon shutdown). A connect that was + * in-flight when the shutdown snapshot was taken — e.g. one that outlived + * the bounded quiesce grace — must not register a live client afterwards: + * nothing would ever disconnect it once the daemon exits. + */ + private closed = false; + private async withNameLock( name: string, fn: () => Promise, @@ -233,6 +241,7 @@ export class ConnectionRegistry { serverSettings?: InspectorServerSettings; serverIdentity: string; }): Promise { + this.assertOpen(); this.clearIdleTimer(); try { @@ -266,6 +275,14 @@ export class ConnectionRegistry { const now = Date.now(); const auth = await getConnectionAuthInfo(client); + if (this.closed) { + // Shutdown proceeded past its bounded quiesce grace while this + // connect was still in flight; the disconnectAll snapshot has already + // run, so registering now would leak a live transport/child process + // past daemon exit. Tear the fresh client down instead. + await safeDisconnect(client); + this.assertOpen(); + } this.connections.set(params.name, { name: params.name, serverIdentity: params.serverIdentity, @@ -328,6 +345,7 @@ export class ConnectionRegistry { } async disconnectAll(): Promise { + this.closed = true; const names = [...this.connections.keys()]; for (const name of names) { await this.disconnect(name, false); @@ -335,6 +353,16 @@ export class ConnectionRegistry { this.clearIdleTimer(); } + private assertOpen(): void { + if (this.closed) { + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Connection daemon is shutting down.", + { code: "daemon_stopping" }, + ); + } + } + private armIdleTimer(): void { this.clearIdleTimer(); if (this.idleMs <= 0 || !this.onIdle) return; From 2bd7a68f5af52c5a5daf8f68a96655a88e06f324 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 09:11:11 -0700 Subject: [PATCH 19/69] fix(daemon-cli,core): address Copilot review round 10 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stream-client: unsolicited socket error or EOF-without-end-frame during streaming now fails with daemon_unreachable instead of exiting 0; only an explicit end frame or a caller abort is a clean finish - form-schema: reject internally inconsistent fields — unsatisfiable constraints (min>max, minItems above choice count) and defaults that violate their own constraints (out of range, not in enum, non-integer) - mcp connect: a single path-like token (contains a separator, or starts with . or ~) is now an ad-hoc stdio target; a bare word remains a catalog/config name (documented in the target argument help) - runner-interactive-oauth: attach an early no-op rejection observer to flowDone so a signal during server startup (before Promise.race subscribes) no longer produces an unhandled rejection Regression tests for each; daemon-cli validate 287 pass, coverage thresholds met, full local gate green. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-stream.test.ts | 23 ++--- .../daemon-cli/__tests__/form-schema.test.ts | 80 +++++++++++++++++ .../daemon-cli/__tests__/mcp-coverage.test.ts | 23 +++++ .../daemon-cli/src/connection/form-schema.ts | 90 +++++++++++++++++++ clients/daemon-cli/src/connection/mcp.ts | 24 ++++- .../daemon-cli/src/daemon/stream-client.ts | 24 ++++- .../auth/runner-interactive-oauth.test.ts | 58 ++++++++++++ core/auth/node/runner-interactive-oauth.ts | 7 ++ 8 files changed, 313 insertions(+), 16 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index 0cfb3b722b..3beaa9337d 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -85,7 +85,7 @@ describe("streamDaemon + ipc-glue", () => { expect(data).toEqual([{ n: 1 }]); }); - it("resolves on socket error after the stream has opened", async () => { + it("rejects on socket error after the stream has opened", async () => { const sock = freshSock(); await listen(sock, (socket) => { socket.once("data", (buf) => { @@ -96,10 +96,11 @@ describe("streamDaemon + ipc-glue", () => { setTimeout(() => socket.destroy(), 20); }); }); - await streamDaemon( - {}, - { socketPath: sock, timeoutMs: 2000, onData: () => {} }, - ); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + }); }); it("rejects malformed stream frames after open", async () => { @@ -206,7 +207,7 @@ describe("streamDaemon + ipc-glue", () => { }); }); - it("resolves when the peer closes mid-stream", async () => { + it("rejects when the peer closes mid-stream without an end frame", async () => { const sock = freshSock(); await listen(sock, (socket) => { socket.once("data", (buf) => { @@ -217,10 +218,12 @@ describe("streamDaemon + ipc-glue", () => { socket.end(); }); }); - await streamDaemon( - {}, - { socketPath: sock, timeoutMs: 2000, onData: () => {} }, - ); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + message: expect.stringMatching(/closed the stream before it ended/), + }); }); it("uses env-derived defaults and sends an explicit token", async () => { diff --git a/clients/daemon-cli/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts index 2700517e88..cae0015bb7 100644 --- a/clients/daemon-cli/__tests__/form-schema.test.ts +++ b/clients/daemon-cli/__tests__/form-schema.test.ts @@ -322,4 +322,84 @@ describe("parseFormSchema", () => { }); expect(fields?.[0].required).toBe(false); }); + + // Internally inconsistent fields are rejected like any other malformed + // schema: unsatisfiable constraints or a default violating its own + // constraints would render unwinnable / instantly-invalid prompts. + it("returns null for unsatisfiable constraints", () => { + const cases: Record[] = [ + { n: { type: "number", minimum: 10, maximum: 5 } }, + { s: { type: "string", minLength: 5, maxLength: 2 } }, + { m: { type: "array", items: { enum: ["a"] }, minItems: 2 } }, + { + m: { + type: "array", + items: { enum: ["a", "b"] }, + minItems: 2, + maxItems: 1, + }, + }, + ]; + for (const properties of cases) { + expect(parseFormSchema({ type: "object", properties })).toBeNull(); + } + }); + + it("returns null for defaults that violate the field's own constraints", () => { + const cases: Record[] = [ + { n: { type: "number", minimum: 1, maximum: 10, default: 11 } }, + { n: { type: "number", minimum: 1, default: 0 } }, + { i: { type: "integer", default: 1.5 } }, + { s: { type: "string", minLength: 3, default: "ab" } }, + { s: { type: "string", maxLength: 2, default: "abc" } }, + { e: { type: "string", enum: ["a", "b"], default: "c" } }, + { + e: { + type: "string", + oneOf: [{ const: "a", title: "A" }], + default: "b", + }, + }, + { m: { type: "array", items: { enum: ["a", "b"] }, default: ["c"] } }, + { + m: { + type: "array", + items: { enum: ["a", "b"] }, + minItems: 2, + default: ["a"], + }, + }, + { + m: { + type: "array", + items: { enum: ["a", "b"] }, + maxItems: 1, + default: ["a", "b"], + }, + }, + ]; + for (const properties of cases) { + expect(parseFormSchema({ type: "object", properties })).toBeNull(); + } + }); + + it("accepts consistent constraints with in-range defaults", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + n: { type: "number", minimum: 1, maximum: 10, default: 5 }, + i: { type: "integer", minimum: 0, default: 0 }, + s: { type: "string", minLength: 1, maxLength: 3, default: "ab" }, + e: { type: "string", enum: ["a", "b"], default: "b" }, + m: { + type: "array", + items: { enum: ["a", "b"] }, + minItems: 1, + maxItems: 2, + default: ["a", "b"], + }, + }, + }); + expect(fields).toHaveLength(5); + }); }); diff --git a/clients/daemon-cli/__tests__/mcp-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-coverage.test.ts index c6240cb832..40fdd96335 100644 --- a/clients/daemon-cli/__tests__/mcp-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-coverage.test.ts @@ -408,6 +408,29 @@ describe("mcp.ts coverage", () => { await runMcp(["daemon", "stop", "--format", "json"], { env: e }); }); + it("treats a single path-like token as ad-hoc stdio, a bare word as a catalog name", async () => { + configPath = createSampleTestConfig(); + const e = { ...env(), MCP_CATALOG_PATH: configPath }; + + // Bare word: catalog/config lookup — fails as a catalog miss, before + // any daemon or spawn work. + const bareWord = await runMcp( + ["connect", "no-such-catalog-entry", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(bareWord); + expect(bareWord.stderr).toMatch(/not found/i); + + // Path-like token: ad-hoc stdio target — never touches the catalog, so + // the failure is a spawn/connect failure, not a catalog miss. + const pathToken = await runMcp( + ["connect", "./no-such-server-binary", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(pathToken); + expect(pathToken.stderr).not.toMatch(/not found\. Available servers/); + }); + it("bare mcpdo / --help print usage without an ErrorEnvelope", async () => { // Bare invocation: Commander writes help to stderr (help-after-error). const bare = await runMcp([]); diff --git a/clients/daemon-cli/src/connection/form-schema.ts b/clients/daemon-cli/src/connection/form-schema.ts index 723d8e0d52..ee0e0298e7 100644 --- a/clients/daemon-cli/src/connection/form-schema.ts +++ b/clients/daemon-cli/src/connection/form-schema.ts @@ -76,7 +76,97 @@ function parseChoicesFromOneOf(value: unknown): Choice[] | undefined { return choices; } +/** + * A structurally valid field can still be internally inconsistent — + * unsatisfiable constraints (`minimum > maximum`, `minItems` above the + * choice count) or a default that violates its own constraints. Those would + * render unwinnable or instantly-invalid prompts, so treat them like any + * other malformed schema and reject the field. + */ +function isConsistent(field: FieldExtra): boolean { + switch (field.kind) { + case "boolean": + return true; + case "number": + if ( + field.minimum !== undefined && + field.maximum !== undefined && + field.minimum > field.maximum + ) { + return false; + } + if (field.default !== undefined) { + if (field.integer && !Number.isInteger(field.default)) return false; + if (field.minimum !== undefined && field.default < field.minimum) + return false; + if (field.maximum !== undefined && field.default > field.maximum) + return false; + } + return true; + case "string": + if ( + field.minLength !== undefined && + field.maxLength !== undefined && + field.minLength > field.maxLength + ) { + return false; + } + if (field.default !== undefined) { + if ( + field.minLength !== undefined && + field.default.length < field.minLength + ) { + return false; + } + if ( + field.maxLength !== undefined && + field.default.length > field.maxLength + ) { + return false; + } + } + return true; + case "enum": + return ( + field.default === undefined || + field.choices.some((c) => c.value === field.default) + ); + case "multiselect": { + if ( + field.minItems !== undefined && + field.maxItems !== undefined && + field.minItems > field.maxItems + ) { + return false; + } + if ( + field.minItems !== undefined && + field.minItems > field.choices.length + ) { + return false; + } + const def = field.default; + if (def !== undefined) { + if (!def.every((v) => field.choices.some((c) => c.value === v))) { + return false; + } + if (field.minItems !== undefined && def.length < field.minItems) + return false; + if (field.maxItems !== undefined && def.length > field.maxItems) + return false; + } + return true; + } + } +} + function parseField(prop: unknown): FieldExtra | null { + const parsed = parseFieldShape(prop); + if (!parsed || !isConsistent(parsed)) return null; + return parsed; +} + +function parseFieldShape(prop: unknown): FieldExtra | null { if (!isRecord(prop)) return null; const type = prop.type; diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 796a332d60..93b957a163 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -253,7 +253,10 @@ function registerConnect(program: CommandType): void { ) .argument( "[target...]", - "Catalog entry name, or command/URL (use -- for command args)", + "Catalog entry name, or command/URL (use -- for command args). A " + + "single bare word is a catalog name; a URL, a path (contains / or " + + "starts with . or ~), multiple tokens, or --transport force an " + + "ad-hoc target.", ) .option("--server ", "Server name from catalog/config") .option( @@ -368,7 +371,8 @@ function registerConnect(program: CommandType): void { rest.length > 1 || Boolean(cmdOpts.transport) || Boolean(cmdOpts.serverUrl?.trim()) || - (rest.length === 1 && looksLikeUrl(rest[0]!)); + (rest.length === 1 && + (looksLikeUrl(rest[0]!) || looksLikePath(rest[0]!))); const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH; const serverOptions = { @@ -1165,6 +1169,22 @@ function looksLikeUrl(value: string): boolean { return /^https?:\/\//i.test(value); } +/** + * A single positional token is ambiguous between a catalog entry name and a + * bare stdio command. Disambiguate deterministically: a token that looks + * like a filesystem path (contains a separator, or starts with `.` or `~`) + * is an ad-hoc stdio target; a bare word is a catalog/config name. A bare + * command name can still be run ad-hoc with an explicit `--transport stdio`. + */ +function looksLikePath(value: string): boolean { + return ( + value.includes("/") || + value.includes("\\") || + value.startsWith(".") || + value.startsWith("~") + ); +} + function splitConnectionTarget(target: string[]): { name: string | undefined; rest: string[]; diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index abf6d89c43..d675d2ac36 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -125,7 +125,16 @@ export async function streamDaemon( socket.on("error", (err) => { if (streaming) { - succeed(); + // A socket error mid-stream means the daemon crashed or the + // transport broke — not a clean finish. A deliberate cancel settles + // first via onAbort, so only unsolicited errors reach here. + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Connection daemon stream failed: ${err.message}`, + { code: "daemon_unreachable" }, + ), + ); return; } fail( @@ -139,10 +148,17 @@ export async function streamDaemon( socket.on("close", () => { if (settled) return; - // Soft-end after the ok frame; pre-response FIN is unreachable (mirrors - // the error handler and callDaemon's close guard). + // Only an explicit `end` frame (or a caller abort, which settles via + // onAbort before destroying) is a clean finish. EOF without `end` + // means the daemon exited or dropped the socket mid-stream. if (streaming) { - succeed(); + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Connection daemon closed the stream before it ended`, + { code: "daemon_unreachable" }, + ), + ); return; } fail( diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index b89a8b9880..dd72d84ecc 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -587,6 +587,64 @@ describe("runRunnerInteractiveOAuth", () => { expect(process.listenerCount("SIGTERM")).toBe(0); }); + it("handles a signal during server startup without an unhandled rejection", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + let releaseStart!: () => void; + const startGate = new Promise((resolve) => (releaseStart = resolve)); + const mockServer = { + start: vi.fn(async (opts: OAuthCallbackServerStartOptions) => { + handlers.current = { + onCallback: opts.onCallback, + onError: opts.onError, + }; + await startGate; + return { + port: 6276, + redirectUrl: "http://127.0.0.1:6276/oauth/callback", + }; + }), + stop: vi.fn(async () => {}), + } as unknown as OAuthCallbackServer; + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + handleSignals: true, + }); + + // The signal listeners are installed before `server.start()` is + // awaited, so a signal in that window rejects flowDone before the + // Promise.race ever subscribes to it. + await Promise.resolve(); + process.emit("SIGINT", "SIGINT"); + // A full macrotask turn: Node reports any unhandled rejection here. + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + + releaseStart(); + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGINT).", + ); + expect(mockServer.stop).toHaveBeenCalled(); + expect(process.listenerCount("SIGINT")).toBe(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + it("installs no signal listeners unless handleSignals is set (TUI owns Ctrl-C via Ink)", async () => { const redirectUrlProvider = { redirectUrl: "" }; const mockServer = createMockCallbackServer(handlers); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index 16597ffd4e..b87c586c9a 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -85,6 +85,13 @@ export async function runRunnerInteractiveOAuth( flowResolve = resolve; flowReject = reject; }); + // flowDone can reject before the Promise.race below ever subscribes — a + // signal (or an early callback error) while `server.start()` is still + // awaited would otherwise surface as an unhandled rejection. This no-op + // observer marks it handled for that window; the race still receives the + // rejection through its own subscription. + // void: intentional fire-and-forget rejection observer (see comment above) + void flowDone.catch(() => {}); // Ctrl-C / a caller killing the process while waiting on the loopback // callback would otherwise either hang until the timeout below or (for From 3166123e51beefc9d779f87e1601bdc9532dd082 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 09:39:32 -0700 Subject: [PATCH 20/69] fix(daemon-cli): address Copilot review round 11 on #1783 - format-connection: escape C1 controls (U+0080-U+009F, incl. 8-bit CSI/OSC) as \uXXXX in --format json output; JSON.stringify only escapes C0, so a malicious server result could otherwise drive terminal control sequences. Parsed values are byte-identical. - format-human: always render structuredContent once in tool results; the duplicate-text filter could previously drop the structured payload entirely when the JSON copy appeared alongside other content blocks. Regression tests for both; daemon-cli validate 288 pass, coverage thresholds met, full local gate green. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/format-connection.test.ts | 26 ++++++++++++++++++- .../src/connection/format-connection.ts | 15 +++++++++-- .../daemon-cli/src/connection/format-human.ts | 6 ++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index be7f036967..e3f22f4a4a 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -191,7 +191,10 @@ describe("format-human", () => { expect(withDupe).toContain("[Audio:"); expect(withDupe).toContain("Embedded resource"); expect(withDupe).toContain('"x": 1'); - expect(withDupe).not.toContain("Structured content:"); + // The JSON duplicate of structuredContent is filtered from the content + // blocks, but the structured payload itself must still be rendered once. + expect(withDupe).toContain("Structured content:"); + expect(withDupe).toContain('"ok": true'); expect( formatCallToolResultHuman({ @@ -598,6 +601,27 @@ describe("writeConnectionOutput", () => { expect(stdout).toBe('{\n "tools": []\n}\n'); }); + it("escapes C1 controls in json output (JSON.stringify only escapes C0)", async () => { + await writeConnectionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { + content: [{ type: "text", text: "before\u009b31mafter" }], + }, + }, + ); + // U+009B is 8-bit CSI: it must reach the terminal as a \u escape, and + // parsing the output must restore the original value byte-for-byte. + expect(stdout).not.toContain("\u009b"); + expect(stdout).toContain("\\u009b"); + const parsed = JSON.parse(stdout) as { + content: { text: string }[]; + }; + expect(parsed.content[0]!.text).toBe("before\u009b31mafter"); + }); + it("sanitizes server-supplied terminal escapes in text mode", async () => { await writeConnectionOutput( { format: "text" }, diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 7d53577654..45898725f4 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -28,9 +28,19 @@ type JsonObject = Record; * Pretty-print JSON for connection `--format json`. * Unlike one-shot, this does **not** wrap in `{ result }` — the payload is the * MCP / admin object itself (convenient for scripting). + * + * `JSON.stringify` escapes C0 controls but emits C1 controls (U+0080–U+009F, + * including 8-bit CSI/OSC) literally, which terminals can interpret. Escape + * them as standard `\uXXXX` sequences: the serialized text is terminal-safe + * while parsed values stay byte-identical. */ export function formatConnectionJson(data: unknown): string { - return JSON.stringify(data, null, 2) + "\n"; + return ( + JSON.stringify(data, null, 2).replace( + /[\u0080-\u009F]/g, + (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`, + ) + "\n" + ); } export type ConnectionWriteKind = @@ -123,7 +133,8 @@ export async function writeConnectionOutput( // injection: OSC 52 clipboard writes, title spoofing, output rewriting). // Sanitize the whole payload before human formatting; the formatter's own // ANSI styling is applied afterwards and stays intact. JSON output above - // is already safe — JSON.stringify escapes control characters. + // is made safe by formatConnectionJson (C0 via JSON.stringify, C1 via its + // own escaping). await awaitableLog(humanPayload(sanitizeDeep(payload), style) + "\n"); await writeNdjsonSummary(payload); applyExitCodes(payload); diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index cd8aa8e5e8..cd6999cb50 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -291,7 +291,11 @@ export function formatCallToolResultHuman( } } - if (hasStructuredContent && visible.length === 0) { + // Always render structuredContent once. The duplicate-text filter above + // may have removed its JSON copy from the content blocks, so gating this + // on `visible.length === 0` would drop the structured payload whenever + // any other content block is present alongside the duplicate. + if (hasStructuredContent) { if (lines.length > 0) lines.push(""); lines.push(heading(style, "Structured content:")); lines.push(JSON.stringify(sc, null, 2)); From 8b488556de1fc2a30211c8ff4932e493c78cc4a3 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 10:51:53 -0700 Subject: [PATCH 21/69] fix(daemon-cli): address Copilot review round 12 on #1783 - connections: track in-flight connects (pendingConnects) so the idle shutdown timer neither arms nor fires while a connect is still awaiting client.connect(). A concurrent failed connect (or a disconnect) for a different name could previously re-arm the timer and stop the daemon under a valid pending connect, failing it with daemon_stopping. disconnectLocked's direct arm now goes through the same guarded helper, and arming is skipped entirely once the registry is closed. Regression test covers a failing connect racing a gated slow connect (onIdle never fires; self-reaping still works once the registry empties). daemon-cli validate 289 pass, coverage thresholds met, full gate green. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-connections.test.ts | 60 +++++++++++++++++++ clients/daemon-cli/src/daemon/connections.ts | 37 ++++++++---- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 2a6a4f8974..e19ca06c97 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -341,6 +341,66 @@ describe("ConnectionRegistry", () => { } }); + it("idle timer does not fire while another connect is still in flight", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + let releaseConnect!: () => void; + const gate = new Promise((resolve) => (releaseConnect = resolve)); + let dials = 0; + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockImplementation(() => { + dials++; + // First dial (the slow, valid connect) blocks on the gate; the + // second (a concurrent connect for a different name) fails. + return dials === 1 ? gate : Promise.reject(new Error("dial failed")); + }); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(25); + const onIdle = vi.fn(); + registry.setIdleHandler(onIdle); + try { + const config = { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + } as const; + const identity = "https://mcp.example.com/mcp"; + const slow = registry.connect({ + name: "slow", + serverConfig: config, + serverIdentity: identity, + }); + await vi.waitFor(() => expect(connectSpy).toHaveBeenCalled()); + await expect( + registry.connect({ + name: "fail", + serverConfig: config, + serverIdentity: identity, + }), + ).rejects.toThrow("dial failed"); + // The failed connect must not arm the idle timer while the valid + // connect is still in flight — the daemon would otherwise stop under + // it and fail it with daemon_stopping. + expect(registry.idleRemainingMs()).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(onIdle).not.toHaveBeenCalled(); + releaseConnect(); + await expect(slow).resolves.toMatchObject({ name: "slow" }); + expect(registry.connectionCount()).toBe(1); + // Self-reaping still works once the registry actually empties. + await registry.disconnect("slow", false); + await vi.waitFor(() => expect(onIdle).toHaveBeenCalled()); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const { NodeOAuthStorage, resetNodeOAuthStorageCache } = diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 70cd6bbbe7..5131adc036 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -113,12 +113,23 @@ export class ConnectionRegistry { } /** - * Arm the idle shutdown timer when there are no connections. - * Called at daemon start so a spawn that never connects still self-reaps, - * and after a failed connect that left the registry empty. + * Connects currently in flight (past {@link connectLocked} entry, not yet + * registered or failed). The idle timer must not fire while one is + * pending: a *different* name's failed connect (or a disconnect) would + * otherwise re-arm the timer and stop the daemon under a valid connect + * still awaiting `client.connect()`. + */ + private pendingConnects = 0; + + /** + * Arm the idle shutdown timer when there are no connections and no + * connects in flight. Called at daemon start so a spawn that never + * connects still self-reaps, and after a failed connect/disconnect that + * left the registry empty. */ armIdleTimerIfEmpty(): void { - if (this.connections.size === 0) { + if (this.closed) return; + if (this.connections.size === 0 && this.pendingConnects === 0) { this.armIdleTimer(); } } @@ -243,6 +254,7 @@ export class ConnectionRegistry { }): Promise { this.assertOpen(); this.clearIdleTimer(); + this.pendingConnects++; try { if (this.connections.has(params.name)) { @@ -304,12 +316,13 @@ export class ConnectionRegistry { protocolEra: client.getProtocolEra(), ...(auth && { auth }), }; - } catch (error) { - // Any failure after clearIdleTimer (createConnectionClient, reconnect - // disconnect, client.connect, …) must re-arm so a connection-less daemon - // still self-reaps. + } finally { + this.pendingConnects--; + // Re-arm on any exit. On success the registered connection makes this + // a no-op; on failure (createConnectionClient, reconnect disconnect, + // client.connect, …) it restores self-reaping — but only once no other + // connect is still in flight. this.armIdleTimerIfEmpty(); - throw error; } } @@ -338,9 +351,7 @@ export class ConnectionRegistry { this.mruName = remaining[0]?.name ?? null; } await safeDisconnect(connection.client); - if (this.connections.size === 0) { - this.armIdleTimer(); - } + this.armIdleTimerIfEmpty(); return { name: connectionName }; } @@ -370,7 +381,7 @@ export class ConnectionRegistry { this.idleTimer = setTimeout(() => { this.idleTimer = null; this.idleDeadline = null; - if (this.connections.size === 0) { + if (this.connections.size === 0 && this.pendingConnects === 0) { this.onIdle?.(); } }, this.idleMs); From f751a2aee96ae4728e7544095d775a53af17cf79 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 11:25:03 -0700 Subject: [PATCH 22/69] fix(daemon-cli): address Copilot review round 13 on #1783 - mcp: stop --conn alias expansion at the first -- separator; everything after it belongs to the child process (connect ... -- ) and now passes through verbatim instead of being rewritten to --connection. Regression tests cover expansion before the separator, passthrough after it, and multiple separators. daemon-cli validate 290 pass, coverage thresholds met, full local gate green. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/hoist-connection.test.ts | 29 +++++++++++++++++++ clients/daemon-cli/src/connection/mcp.ts | 19 ++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/clients/daemon-cli/__tests__/hoist-connection.test.ts b/clients/daemon-cli/__tests__/hoist-connection.test.ts index 3d1cd7d9a7..13dedf89a8 100644 --- a/clients/daemon-cli/__tests__/hoist-connection.test.ts +++ b/clients/daemon-cli/__tests__/hoist-connection.test.ts @@ -47,4 +47,33 @@ describe("expandConnAlias", () => { ]; expect(expandConnAlias(input)).toEqual(input); }); + + it("passes tokens after -- through verbatim (child-process args)", () => { + expect( + expandConnAlias([ + "node", + "mcpdo", + "--conn", + "alpha", + "connect", + "srv", + "--", + "--conn=value", + "--conn", + ]), + ).toEqual([ + "node", + "mcpdo", + "--connection", + "alpha", + "connect", + "srv", + "--", + "--conn=value", + "--conn", + ]); + // Only the first separator ends expansion; later ones are child args too. + const onlyAfter = ["node", "mcpdo", "--", "--conn", "--", "--conn=x"]; + expect(expandConnAlias(onlyAfter)).toEqual(onlyAfter); + }); }); diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 93b957a163..62d9b311d4 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -106,14 +106,21 @@ const validLogLevels: LoggingLevel[] = Object.values(LoggingLevelSchema.enum); * `--conn` is a documented shorthand for `--connection`. Expanding it at the * argv level keeps a single option registration (one help entry, one * GlobalOpts field) instead of two options merged at every consumption site. + * Expansion stops at the first `--`: everything after the separator belongs + * to the child process (`connect … -- `) and must pass through + * verbatim. */ export function expandConnAlias(argv: string[]): string[] { - return argv.map((arg) => - arg === "--conn" - ? "--connection" - : arg.startsWith("--conn=") - ? `--connection=${arg.slice("--conn=".length)}` - : arg, + const sep = argv.indexOf("--"); + const end = sep === -1 ? argv.length : sep; + return argv.map((arg, i) => + i >= end + ? arg + : arg === "--conn" + ? "--connection" + : arg.startsWith("--conn=") + ? `--connection=${arg.slice("--conn=".length)}` + : arg, ); } From 49d46f9c34068cfa9da0c8c19083acd0e9ba7a4b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 12:12:21 -0700 Subject: [PATCH 23/69] fix(daemon-cli): address Copilot review round 14 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ipc-glue: bound daemon-side stream buffering (MAX_STREAM_BUFFER_BYTES, 1 MiB of socket writableLength); on overflow stop the producer and destroy the socket without an end frame so the client reports an interrupted stream instead of the daemon buffering without limit - server: make stale-lock reclaim atomic — rename the stale lock aside (one winner per stale file), re-check the claimed pid, and restore a live lock stolen in the read->rename window via create-only link(); releaseLock now only unlinks a lock this process still owns - form-prompt: review lines show "Title (name)" when they differ, and the edit prompt also accepts the displayed title - form-schema: reject negative or non-integer minLength/maxLength and minItems/maxItems as inconsistent instead of silently accepting them - spec: drop the stale streamDaemon post-open-errors to-do row (behavior shipped in round 10) Regression tests for the buffer cap, lock reclaim race, ownership-checked release, review labels, and count keywords. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-coverage.test.ts | 42 ++++++++++++++ .../__tests__/daemon-stream.test.ts | 51 ++++++++++++++++- .../daemon-cli/__tests__/form-prompt.test.ts | 18 ++++++ .../daemon-cli/__tests__/form-schema.test.ts | 21 +++++++ .../daemon-cli/src/connection/form-prompt.ts | 13 ++++- .../daemon-cli/src/connection/form-schema.ts | 16 ++++++ clients/daemon-cli/src/daemon/ipc-glue.ts | 40 +++++++++++-- clients/daemon-cli/src/daemon/server.ts | 57 +++++++++++++++---- specification/v2_cli_v2.md | 1 - 9 files changed, 239 insertions(+), 20 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index f5a5d1e20c..d71b129291 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import * as fs from "node:fs"; + +// Wrap renameSync in a pass-through vi.fn so the lock-reclaim race test can +// inject a concurrent contender in the read→rename window (ESM namespaces +// cannot be spied on directly). +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, renameSync: vi.fn(actual.renameSync) }; +}); import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; @@ -146,6 +154,40 @@ describe("daemon coverage", () => { ); }); + it("does not delete a lock it no longer owns on stop", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const lockPath = path.join(d, "daemon.lock"); + // Simulate a successor's lock at the same path (reclaim race / manual + // operator cleanup): release must be ownership-checked. + fs.writeFileSync(lockPath, "424242\n"); + await server.stop("stop"); + server = undefined; + expect(fs.readFileSync(lockPath, "utf8").trim()).toBe("424242"); + fs.unlinkSync(lockPath); + }); + + it("restores a live lock created between the dead-pid read and the rename", async () => { + const d = freshDir(); + const lockPath = path.join(d, "daemon.lock"); + fs.writeFileSync(lockPath, "999999999\n"); // dead pid + const actualFs = await vi.importActual("node:fs"); + vi.mocked(fs.renameSync).mockImplementationOnce((( + ...args: Parameters + ) => { + // Simulate a concurrent starter finishing its own reclaim + O_EXCL + // create in the window between readLockPid() and renameSync(). + fs.writeFileSync(lockPath, `${process.pid}\n`); + return actualFs.renameSync(...args); + }) as typeof fs.renameSync); + const contender = new DaemonServer({ dir: d, idleMs: 0 }); + await expect(contender.start()).rejects.toThrow(/held by running pid/); + // The stolen live lock was restored at the canonical path. + expect(fs.readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false); + }); + it("removes a stale socket before binding", async () => { const d = freshDir(); const sock = path.join(d, "daemon.sock"); diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index 3beaa9337d..d59d76ffb5 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import * as fs from "node:fs"; import * as net from "node:net"; import * as os from "node:os"; @@ -357,6 +357,55 @@ describe("streamDaemon + ipc-glue", () => { expect(stopCalled).toBe(true); }); + it("terminates a stream once the socket write buffer exceeds the cap", async () => { + const sock = freshSock(); + let stopCalled = false; + let writeFn: ((data: unknown) => void) | undefined; + await listen(sock, (socket) => { + acceptDaemonConnection(socket, async (req) => ({ + response: { id: req.id, ok: true, result: {} }, + startStream: (writeData) => { + writeFn = writeData; + return () => { + stopCalled = true; + }; + }, + })); + }); + + let sawEnd = false; + let client!: net.Socket; + const closed = new Promise((resolve) => { + client = net.connect(sock, () => { + sockets.add(client); + // Never read: the daemon-side write buffer must hit the cap instead + // of growing without bound. + client.pause(); + client.write( + JSON.stringify({ id: "s1", op: "stream", params: {} }) + "\n", + ); + }); + client.on("data", (c) => { + if (String(c).includes('"stream":"end"')) sawEnd = true; + }); + client.on("close", () => resolve()); + client.on("error", () => {}); + }); + + await vi.waitFor(() => expect(writeFn).toBeDefined()); + const chunk = "x".repeat(64 * 1024); + for (let i = 0; i < 200 && !stopCalled; i++) { + writeFn!({ chunk }); + } + // Producer unsubscribed and socket destroyed — no clean end frame. + expect(stopCalled).toBe(true); + // The paused client never drains, so its "close" only fires once the + // test tears the socket down. + client.destroy(); + await closed; + expect(sawEnd).toBe(false); + }); + it("unreachable socket path fails before streaming", async () => { await expect( streamDaemon( diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index 9ddcdb9849..30fcc23eb0 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -398,6 +398,24 @@ describe("promptForm", () => { expect(outcome).toEqual({ action: "accept", content: { name: "edited" } }); }); + it("shows the property name in the review when it differs from the title, and edits by title", async () => { + const field: FormField = { + name: "emailAddress", + required: true, + title: "Email address", + kind: "string", + }; + // Initial value, edit via the display title, new value, submit. + const rl = fakeRl(["a@example.com", "Email address", "b@example.com", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { emailAddress: "b@example.com" }, + }); + // The review label must reveal the editable property name. + expect(stderr).toContain("Email address (emailAddress):"); + }); + it("shows '(none)' in the review for a field with no value", async () => { const field: FormField = { ...stringField, required: false }; const rl = fakeRl(["", ""]); diff --git a/clients/daemon-cli/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts index cae0015bb7..a4baacdc14 100644 --- a/clients/daemon-cli/__tests__/form-schema.test.ts +++ b/clients/daemon-cli/__tests__/form-schema.test.ts @@ -345,6 +345,27 @@ describe("parseFormSchema", () => { } }); + it("returns null for negative or non-integer length/count keywords", () => { + const cases: Record[] = [ + { s: { type: "string", maxLength: -1 } }, + { s: { type: "string", minLength: -3 } }, + { s: { type: "string", minLength: 1.5 } }, + { m: { type: "array", items: { enum: ["a", "b"] }, maxItems: -1 } }, + { m: { type: "array", items: { enum: ["a", "b"] }, minItems: -2 } }, + { m: { type: "array", items: { enum: ["a", "b"] }, minItems: 0.5 } }, + ]; + for (const properties of cases) { + expect(parseFormSchema({ type: "object", properties })).toBeNull(); + } + // Zero is a valid bound. + expect( + parseFormSchema({ + type: "object", + properties: { s: { type: "string", minLength: 0 } }, + }), + ).toHaveLength(1); + }); + it("returns null for defaults that violate the field's own constraints", () => { const cases: Record[] = [ { n: { type: "number", minimum: 1, maximum: 10, default: 11 } }, diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index 65c5c332b3..a12da18029 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -234,8 +234,15 @@ export async function promptForm( process.stderr.write(`\n${style.bold("Review your answers:")}\n`); for (const field of fields) { const v = values.get(field.name); + // The edit prompt below accepts the schema property *name*; show it + // whenever it differs from the display title so the user can discover + // what to type. + const label = + field.title === field.name + ? field.title + : `${field.title} (${field.name})`; process.stderr.write( - ` ${sanitizeText(field.title)}: ${v === undefined ? style.dim("(none)") : sanitizeText(String(v))}\n`, + ` ${sanitizeText(label)}: ${v === undefined ? style.dim("(none)") : sanitizeText(String(v))}\n`, ); } const answer = ( @@ -256,7 +263,9 @@ export async function promptForm( if (answer.toLowerCase() === "c") { return { action: "cancel" }; } - const field = fields.find((f) => f.name === answer); + const field = + fields.find((f) => f.name === answer) ?? + fields.find((f) => f.title === answer); if (!field) { process.stderr.write( style.red(` Unknown field "${answer}". Try again.\n`), diff --git a/clients/daemon-cli/src/connection/form-schema.ts b/clients/daemon-cli/src/connection/form-schema.ts index ee0e0298e7..87632c51cb 100644 --- a/clients/daemon-cli/src/connection/form-schema.ts +++ b/clients/daemon-cli/src/connection/form-schema.ts @@ -76,6 +76,16 @@ function parseChoicesFromOneOf(value: unknown): Choice[] | undefined { return choices; } +/** + * Length/count keywords (`minLength`, `maxLength`, `minItems`, `maxItems`) + * must be non-negative integers. A negative bound (e.g. `maxLength: -1` on a + * required string) would otherwise parse fine and then reject every possible + * answer — an unwinnable prompt loop. + */ +function isValidCount(value: number | undefined): boolean { + return value === undefined || (Number.isInteger(value) && value >= 0); +} + /** * A structurally valid field can still be internally inconsistent — * unsatisfiable constraints (`minimum > maximum`, `minItems` above the @@ -104,6 +114,9 @@ function isConsistent(field: FieldExtra): boolean { } return true; case "string": + if (!isValidCount(field.minLength) || !isValidCount(field.maxLength)) { + return false; + } if ( field.minLength !== undefined && field.maxLength !== undefined && @@ -132,6 +145,9 @@ function isConsistent(field: FieldExtra): boolean { field.choices.some((c) => c.value === field.default) ); case "multiselect": { + if (!isValidCount(field.minItems) || !isValidCount(field.maxItems)) { + return false; + } if ( field.minItems !== undefined && field.maxItems !== undefined && diff --git a/clients/daemon-cli/src/daemon/ipc-glue.ts b/clients/daemon-cli/src/daemon/ipc-glue.ts index 0831be1ae9..7363409626 100644 --- a/clients/daemon-cli/src/daemon/ipc-glue.ts +++ b/clients/daemon-cli/src/daemon/ipc-glue.ts @@ -46,6 +46,16 @@ export type HandleRequest = ( */ export const MAX_REQUEST_LINE_BYTES = 1024 * 1024; +/** + * Upper bound on unflushed stream-frame bytes buffered for one socket. + * `socket.write()` queues without limit when the peer stops reading; a + * high-rate stream (e.g. `logging/tail`) to a slow client would otherwise + * grow the daemon's heap without bound. Once exceeded, the stream is + * terminated: the producer is unsubscribed and the socket destroyed, which + * the client reports as an interrupted stream (`daemon_unreachable`). + */ +export const MAX_STREAM_BUFFER_BYTES = 1024 * 1024; + /** * Per-connection {@link ElicitationChannel}. Writes an elicitation-request * frame straight onto the socket (ahead of the eventual `DaemonResponse`) and @@ -177,20 +187,38 @@ export function acceptDaemonConnection( const id = request.id; let stopped = false; + let stop: (() => void) | undefined = undefined; + const stopProducer = () => { + try { + stop?.(); + } catch { + // ignore unsubscribe errors + } + }; const writeData = (data: unknown) => { if (stopped || socket.destroyed) return; const frame: DaemonStreamFrame = { id, stream: "data", data }; socket.write(JSON.stringify(frame) + "\n"); + if (socket.writableLength > MAX_STREAM_BUFFER_BYTES) { + // Slow/non-reading client: cap the buffered backlog instead of + // exhausting the daemon heap. Destroying (no end frame) makes the + // client report an interrupted stream rather than a clean finish. + stopped = true; + stopProducer(); + socket.destroy(); + } }; - const stop = outcome.startStream(writeData); + stop = outcome.startStream(writeData); + if (stopped) { + // Overflow hit while startStream was still running (synchronous + // producer): `stop` wasn't assigned yet, unsubscribe it now. + stopProducer(); + return; + } const cleanup = () => { if (stopped) return; stopped = true; - try { - stop(); - } catch { - // ignore unsubscribe errors - } + stopProducer(); if (!socket.destroyed) { const end: DaemonStreamFrame = { id, stream: "end" }; socket.write(JSON.stringify(end) + "\n"); diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index b3a66adaad..f76979eba0 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -505,12 +505,15 @@ export class DaemonServer { /** * `daemon.lock` is a real lock, not bookkeeping: `O_EXCL`-create it with * our pid, and refuse to start while another *live* daemon holds it. A - * lock left by a dead pid is reclaimed (one retry). This closes the race - * where two starting daemons both probe a dead socket, both unlink, and - * the loser's unlink removes the winner's freshly-bound socket. + * lock left by a dead pid is reclaimed atomically: the stale file is + * `rename`d aside first, so exactly one contender wins the reclaim and a + * concurrent starter's freshly-created lock can never be deleted by the + * read-pid → unlink window of another. If the renamed-aside file turns out + * to hold a *live* pid (created between our read and the rename), it is + * restored with a create-only `link` — ownership-preserving, same inode. */ private acquireLock(): void { - for (let attempt = 0; attempt < 2; attempt++) { + for (let attempt = 0; attempt < 3; attempt++) { try { const fd = fs.openSync(this.lockPath, "wx", 0o600); fs.writeSync(fd, `${process.pid}\n`); @@ -526,10 +529,40 @@ export class DaemonServer { { cause: error }, ); } + const claimed = `${this.lockPath}.reclaim.${process.pid}`; try { - fs.unlinkSync(this.lockPath); + fs.renameSync(this.lockPath, claimed); } catch { - // lost a removal race; the retry's O_EXCL create decides + // Another contender renamed it first; retry the O_EXCL create. + continue; + } + const claimedPid = this.readPidFile(claimed); + if (claimedPid !== undefined && isPidAlive(claimedPid)) { + // We renamed away a lock that a concurrent starter created between + // our dead-pid read and the rename. Put it back without breaking + // that starter's ownership: link() re-creates the path for the + // same inode and fails (EEXIST) rather than overwriting. + try { + fs.linkSync(claimed, this.lockPath); + } catch { + // A third contender created a new lock meanwhile; the retry's + // O_EXCL create / live-pid check decides. + } + try { + fs.unlinkSync(claimed); + } catch { + // best-effort temp cleanup + } + throw new Error( + `Connection daemon lock ${this.lockPath} is held by running pid ${claimedPid}. ` + + `Use \`mcpdo daemon/stop\`, or remove the file if that pid is not an mcpdo daemon.`, + { cause: error }, + ); + } + try { + fs.unlinkSync(claimed); + } catch { + // best-effort temp cleanup } } } @@ -539,11 +572,12 @@ export class DaemonServer { } private readLockPid(): number | undefined { + return this.readPidFile(this.lockPath); + } + + private readPidFile(filePath: string): number | undefined { try { - const pid = Number.parseInt( - fs.readFileSync(this.lockPath, "utf8").trim(), - 10, - ); + const pid = Number.parseInt(fs.readFileSync(filePath, "utf8").trim(), 10); return Number.isInteger(pid) && pid > 0 ? pid : undefined; } catch { return undefined; @@ -551,6 +585,9 @@ export class DaemonServer { } private releaseLock(): void { + // Only release a lock this process still owns: after a reclaim race or + // an operator's manual cleanup, the path may hold a successor's lock. + if (this.readLockPid() !== process.pid) return; try { fs.unlinkSync(this.lockPath); } catch { diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 3830ae1905..03fbd5039c 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -169,7 +169,6 @@ Both are wired into root `validate` / `coverage`. | **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | | **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | | **Per-connection RPC mutex** | Parallel `mcpdo` processes against one connection can interleave on one `InspectorClient`. | -| **`streamDaemon` post-open errors** | Socket errors after the initial ok frame are treated as soft end. | | **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | | **Split `registerRpcCommands`** | Large Commander switch in `connection/mcp.ts`. | | **`mcpdo daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | From a64e1dd74b65bbdc1529be822050823bc2ac28e0 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 13:43:53 -0700 Subject: [PATCH 24/69] fix(daemon-cli): address Copilot review round 15 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - paths: on Windows, derive a deterministic per-directory named-pipe path (\\.\pipe\mcp-conn-) instead of a filesystem path that net.listen/connect can never bind; the sun_path length check is skipped for pipe names. Unix behavior unchanged. - form-prompt: JSON Schema `required` means present, not non-empty — a blank answer on a required string is now a valid "" (minLength, if set, still rejects it), and Enter with an empty-string default keeps the default instead of dropping the field. Regression tests: win32 pipe naming (deterministic, case-insensitive, no length limit), blank required strings, minLength on blanks, and empty-string defaults. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../daemon-cli/__tests__/daemon-paths.test.ts | 20 ++++++++++++++++ .../daemon-cli/__tests__/form-prompt.test.ts | 24 +++++++++++++++---- .../daemon-cli/src/connection/form-prompt.ts | 14 ++++++----- clients/daemon-cli/src/daemon/paths.ts | 20 +++++++++++++++- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-paths.test.ts b/clients/daemon-cli/__tests__/daemon-paths.test.ts index c88035cec6..d61f948a72 100644 --- a/clients/daemon-cli/__tests__/daemon-paths.test.ts +++ b/clients/daemon-cli/__tests__/daemon-paths.test.ts @@ -136,6 +136,26 @@ describe("daemon paths", () => { /too long for this platform/, ); }); + + it("uses a deterministic named-pipe path on Windows with no sun_path limit", () => { + const realPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + ) as PropertyDescriptor; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + const pipe = getDaemonSocketPath("/some/daemon/dir"); + expect(pipe).toMatch(/^\\\\\.\\pipe\\mcp-conn-[0-9a-f]{16}$/); + // Same dir (any casing) -> same pipe; different dir -> different pipe. + expect(getDaemonSocketPath("/SOME/DAEMON/DIR")).toBe(pipe); + expect(getDaemonSocketPath("/other/daemon/dir")).not.toBe(pipe); + // Pipe names are not sun_path-constrained. + const long = "\\\\.\\pipe\\" + "x".repeat(300); + expect(() => assertSocketPathWithinLimit(long)).not.toThrow(); + } finally { + Object.defineProperty(process, "platform", realPlatform); + } + }); }); describe("writeFormattedResult", () => { diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index 30fcc23eb0..b333bb712a 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -91,11 +91,27 @@ describe("promptForm", () => { expect(outcome).toEqual({ action: "accept", content: {} }); }); - it("re-prompts a required string field until non-blank", async () => { - const rl = fakeRl(["", "octocat", ""]); + it("accepts a blank answer for a required string field as an empty string", async () => { + // JSON Schema `required` means present, not non-empty. + const rl = fakeRl(["", ""]); const outcome = await promptForm(rl, "msg", [stringField], style); - expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } }); - expect(stderr).toContain("This field is required"); + expect(outcome).toEqual({ action: "accept", content: { name: "" } }); + expect(stderr).not.toContain("This field is required"); + }); + + it("lets minLength reject a blank required answer", async () => { + const field: FormField = { ...stringField, minLength: 3 }; + const rl = fakeRl(["", "abc", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "abc" } }); + expect(stderr).toContain("at least 3"); + }); + + it("keeps an empty-string default instead of dropping the field", async () => { + const field: FormField = { ...stringField, required: false, default: "" }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "" } }); }); it("enforces minLength/maxLength on a string field", async () => { diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index a12da18029..48bdd9c3aa 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -190,12 +190,14 @@ async function promptField( closed, `${describeField(field, style)}\n ${def !== undefined ? `[${sanitizeText(def)}]` : ""}: `, ); - const value = raw === "" && def !== undefined ? def : raw; - if (value === "" && field.required) { - process.stderr.write(style.red(" This field is required.\n")); - continue; - } - if (value === "" && !field.required) return undefined; + // Enter with a default selects it — even an empty-string default; the + // schema gate already rejected defaults violating their own constraints. + if (raw === "" && def !== undefined) return def; + // A blank answer with no default omits an optional field. For a required + // field "" is a value — JSON Schema `required` means present, not + // non-empty — so minLength (if any) decides below. + if (raw === "" && !field.required) return undefined; + const value = raw; if (field.minLength !== undefined && value.length < field.minLength) { process.stderr.write( style.red(` Must be at least ${field.minLength} characters.\n`), diff --git a/clients/daemon-cli/src/daemon/paths.ts b/clients/daemon-cli/src/daemon/paths.ts index 7199ef0d70..d6ca6ad1bd 100644 --- a/clients/daemon-cli/src/daemon/paths.ts +++ b/clients/daemon-cli/src/daemon/paths.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -94,7 +94,23 @@ export function assertTrustedPrivateRoot(dir: string): void { } } +/** + * IPC endpoint for the daemon owning `dir`. On Unix this is a socket file + * inside the directory. On Windows, `net` requires named-pipe paths + * (`\\.\pipe\...`) — a filesystem path never binds — so derive a + * deterministic per-directory pipe name: every client of the same daemon + * dir dials the same pipe, and distinct dirs (private mode, tests) never + * collide. The dir is resolved and lowercased first, matching Windows + * path-comparison semantics. + */ export function getDaemonSocketPath(dir: string = getDaemonDir()): string { + if (process.platform === "win32") { + const hash = createHash("sha256") + .update(path.resolve(dir).toLowerCase()) + .digest("hex") + .slice(0, 16); + return `\\\\.\\pipe\\mcp-conn-${hash}`; + } return path.join(dir, "daemon.sock"); } @@ -128,6 +144,8 @@ export function getDaemonLogPath(dir: string = getDaemonDir()): string { * generic start timeout. Validate up front with an actionable error instead. */ export function assertSocketPathWithinLimit(socketPath: string): void { + // Windows named pipes are not sun_path-constrained. + if (process.platform === "win32") return; /* v8 ignore next -- one arm per platform; CI runs each on its own OS */ const limit = process.platform === "linux" ? 107 : 103; const bytes = Buffer.byteLength(socketPath); From 4028c25f0ac243d08f6f97d7f10ecf8026348876 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 14:20:07 -0700 Subject: [PATCH 25/69] fix(daemon-cli, core): address Copilot review round 16 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon client/stream-client: stop deriving the daemon.token directory from dirname(socketPath) — on Windows that is the pipe namespace, not the daemon dir, so shared-mode calls sent no token. New daemonTokenDir helper: explicit `dir` option wins, a filesystem socketPath implies its directory, otherwise the configured daemon directory. - core/auth: race every awaited OAuth phase (server.start, authenticate, beginInteractiveAuthorization, challenge check) against signal cancellation, so SIGINT/SIGTERM during a stalled startup or authorization rejects cleanly (with callback-server cleanup) instead of leaving the CLI blocked or discarding the cancellation as already_authorized; abandoned/raced promises carry rejection observers so nothing surfaces as unhandled. - docs: project-structure skill describes the transport as local IPC (Unix socket / Windows named pipe); replace stale mcp-conn wording with mcpdo in the README and two comments. Regression tests: daemonTokenDir resolution (explicit dir, Unix socket, pipe path, default), stalled server.start() and stalled authenticate() cancellation (the latter asserting already_authorized is not reported). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/skills/project-structure/SKILL.md | 2 +- clients/daemon-cli/README.md | 8 +- .../__tests__/daemon-coverage.test.ts | 24 +++++- .../daemon-cli/src/connection/authorize.ts | 2 +- clients/daemon-cli/src/connection/ema.ts | 2 +- clients/daemon-cli/src/daemon/client.ts | 37 +++++++- .../daemon-cli/src/daemon/stream-client.ts | 4 +- .../auth/runner-interactive-oauth.test.ts | 77 ++++++++++++++++- core/auth/node/runner-interactive-oauth.ts | 85 +++++++++++++------ 9 files changed, 197 insertions(+), 44 deletions(-) diff --git a/.claude/skills/project-structure/SKILL.md b/.claude/skills/project-structure/SKILL.md index 3ec09ba2e3..1bc21e10ff 100644 --- a/.claude/skills/project-structure/SKILL.md +++ b/.claude/skills/project-structure/SKILL.md @@ -20,7 +20,7 @@ inspector/ │ │ ├── server/ Node-only dev/prod backend wiring (see below) │ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab │ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias) -│ ├── daemon-cli/ The `mcpdo` connection CLI bin; daemon + client over a Unix socket (tsup bundle, @inspector/core alias) +│ ├── daemon-cli/ The `mcpdo` connection CLI bin; daemon + client over local IPC (Unix socket / Windows named pipe; tsup bundle, @inspector/core alias) │ ├── tui/ Ink + React terminal UI (tsup bundle) │ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process ├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json) diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md index abaa54137d..3b132c3aec 100644 --- a/clients/daemon-cli/README.md +++ b/clients/daemon-cli/README.md @@ -114,7 +114,7 @@ local code, so they need no process isolation. mcpdo shares `core`'s `InspectorClient`, so it negotiates whichever era (`legacy` 2025-03-26-style vs. `modern`/2026-era, e.g. task-augmented calls, `server/discover`) the target actually speaks — no extra flags needed for -that to work. Two things are mcp-conn-specific: +that to work. Two things are mcpdo-specific: - **`--era ` on `connect`**: `legacy` (default), `auto` (probe via `server/discover` before connecting), or `modern`. Overrides whatever a @@ -198,10 +198,10 @@ mcpdo connect https://example.com/mcp --elicit url ## Relation to one-shot CLI -| | One-shot | Connection (`mcpdo`) | +| | One-shot | Connection (`mcpdo`) | | ------------- | ------------------------------------- | ------------------------------- | -| Entrypoint | `mcp-inspector --cli` | `mcpdo` | -| Package (dev) | `clients/cli` | `clients/daemon-cli` | +| Entrypoint | `mcp-inspector --cli` | `mcpdo` | +| Package (dev) | `clients/cli` | `clients/daemon-cli` | | Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands | One-shot docs: [`clients/cli/README.md`](../cli/README.md). diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index d71b129291..6322d0bacf 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -13,7 +13,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; import { DaemonServer } from "../src/daemon/server.js"; -import { callDaemon } from "../src/daemon/client.js"; +import { callDaemon, daemonTokenDir } from "../src/daemon/client.js"; import { ensureDaemon, readLogTail, @@ -232,6 +232,28 @@ describe("daemon coverage", () => { }); }); + it("resolves the daemon.token directory without deriving it from pipe paths", () => { + // Explicit dir always wins. + expect(daemonTokenDir({ dir: "/x", socketPath: "/y/daemon.sock" })).toBe( + "/x", + ); + // A Unix socket path implies its directory. + expect(daemonTokenDir({ socketPath: "/y/daemon.sock" })).toBe("/y"); + // A Windows named pipe has no meaningful dirname: fall back to the + // configured daemon directory, where the token is actually published. + const prev = process.env.MCP_INSPECTOR_DAEMON_DIR; + process.env.MCP_INSPECTOR_DAEMON_DIR = "/daemon/dir"; + try { + expect(daemonTokenDir({ socketPath: "\\\\.\\pipe\\mcp-conn-abc" })).toBe( + path.resolve("/daemon/dir"), + ); + expect(daemonTokenDir({})).toBe(path.resolve("/daemon/dir")); + } finally { + if (prev === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR; + else process.env.MCP_INSPECTOR_DAEMON_DIR = prev; + } + }); + it("callDaemon maps error responses and unreachable sockets", async () => { await expect( callDaemon( diff --git a/clients/daemon-cli/src/connection/authorize.ts b/clients/daemon-cli/src/connection/authorize.ts index 7baacd1757..4df7c44f2f 100644 --- a/clients/daemon-cli/src/connection/authorize.ts +++ b/clients/daemon-cli/src/connection/authorize.ts @@ -118,7 +118,7 @@ export async function authorizeInFrontend( } catch (err) { // An EMA server without active install-level IdP config: interactive // OAuth cannot fix this, so replace the core error (which points at the - // web Client Settings dialog only) with mcp-conn-appropriate guidance. + // web Client Settings dialog only) with mcpdo-appropriate guidance. if (isEmaClientNotConfiguredError(err)) { throw new CliExitCodeError( EXIT_CODES.AUTH_REQUIRED, diff --git a/clients/daemon-cli/src/connection/ema.ts b/clients/daemon-cli/src/connection/ema.ts index 8e98ee5ed8..1fe1ef3580 100644 --- a/clients/daemon-cli/src/connection/ema.ts +++ b/clients/daemon-cli/src/connection/ema.ts @@ -34,7 +34,7 @@ function clientConfigPath(): string { } /** - * mcp-conn-flavoured guidance for a missing/disabled EMA client configuration. + * mcpdo-flavoured guidance for a missing/disabled EMA client configuration. * The core `EmaClientNotConfiguredError` message points at the web Client * Settings dialog; mcpdo users may equally well edit `client.json` directly, * so name both, with the resolved path. diff --git a/clients/daemon-cli/src/daemon/client.ts b/clients/daemon-cli/src/daemon/client.ts index 6ee05e2d29..d98d2db402 100644 --- a/clients/daemon-cli/src/daemon/client.ts +++ b/clients/daemon-cli/src/daemon/client.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { getDaemonTokenFromEnv, readDaemonTokenFile } from "./auth.js"; import { encodeRequest } from "./framing.js"; -import { getDaemonSocketPath } from "./paths.js"; +import { getDaemonDir, getDaemonSocketPath } from "./paths.js"; import { sanitizeText } from "../connection/sanitize.js"; import type { DaemonOp, @@ -16,6 +16,14 @@ import type { export type DaemonClientOptions = { socketPath?: string; + /** + * Daemon directory that owns `daemon.token`. On Windows `socketPath` is a + * named pipe (`\\.\pipe\...`), so the token location cannot be derived + * from the endpoint; callers using a non-default directory with an + * explicit `socketPath` should pass it. Defaults to the socket's directory + * for Unix socket paths, else the shared daemon directory. + */ + dir?: string; /** Per-request timeout in ms. */ /** * Client-side deadline for the whole request; `0` disables it. Defaults to @@ -48,6 +56,26 @@ export type DaemonClientOptions = { signal?: AbortSignal; }; +/** + * Directory holding `daemon.token` for a request. An explicit `dir` wins; a + * filesystem `socketPath` implies its directory (Unix sockets live next to + * the token file); otherwise — the default endpoint, or a Windows named + * pipe, which has no meaningful dirname — the shared daemon directory. + */ +export function daemonTokenDir(options: { + dir?: string; + socketPath?: string; +}): string { + if (options.dir !== undefined) return options.dir; + if ( + options.socketPath !== undefined && + !options.socketPath.startsWith("\\\\.\\pipe\\") + ) { + return path.dirname(options.socketPath); + } + return getDaemonDir(); +} + /** * Short-lived NDJSON client for one request/response against the daemon. */ @@ -60,11 +88,14 @@ export async function callDaemon( const timeoutMs = options.timeoutMs ?? 60_000; const id = randomUUID(); // Env token wins (private mode / spawner); otherwise read the token the - // daemon published next to its socket (see getDaemonTokenPath). + // daemon published in its directory (see getDaemonTokenPath). The + // directory is only derived from `socketPath` when that is a filesystem + // path — dirname of a Windows named pipe is the pipe namespace, not the + // daemon dir. const token = options.token ?? getDaemonTokenFromEnv() ?? - readDaemonTokenFile(path.dirname(socketPath)); + readDaemonTokenFile(daemonTokenDir(options)); const request: DaemonRequest = { id, op, params }; if (token !== undefined) request.token = token; diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index d675d2ac36..15fd6ab787 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -3,7 +3,6 @@ */ import { randomUUID } from "node:crypto"; import * as net from "node:net"; -import * as path from "node:path"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { getDaemonTokenFromEnv, readDaemonTokenFile } from "./auth.js"; import { encodeRequest } from "./framing.js"; @@ -14,6 +13,7 @@ import type { DaemonStreamFrame, } from "./protocol.js"; import type { DaemonClientOptions } from "./client.js"; +import { daemonTokenDir } from "./client.js"; import { sanitizeText } from "../connection/sanitize.js"; export type StreamDaemonOptions = DaemonClientOptions & { @@ -36,7 +36,7 @@ export async function streamDaemon( const token = options.token ?? getDaemonTokenFromEnv() ?? - readDaemonTokenFile(path.dirname(socketPath)); + readDaemonTokenFile(daemonTokenDir(options)); const request: DaemonRequest = { id, op: "stream", params }; if (token !== undefined) request.token = token; diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index dd72d84ecc..e885e06d26 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -627,7 +627,12 @@ describe("runRunnerInteractiveOAuth", () => { // The signal listeners are installed before `server.start()` is // awaited, so a signal in that window rejects flowDone before the - // Promise.race ever subscribes to it. + // Promise.race ever subscribes to it. Cancellation now also aborts the + // stalled start() itself, so the runner promise rejects immediately — + // subscribe before emitting the signal. + const expectation = expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGINT).", + ); await Promise.resolve(); process.emit("SIGINT", "SIGINT"); // A full macrotask turn: Node reports any unhandled rejection here. @@ -635,9 +640,7 @@ describe("runRunnerInteractiveOAuth", () => { expect(unhandled).toEqual([]); releaseStart(); - await expect(promise).rejects.toThrow( - "OAuth authorization cancelled (SIGINT).", - ); + await expectation; expect(mockServer.stop).toHaveBeenCalled(); expect(process.listenerCount("SIGINT")).toBe(0); } finally { @@ -645,6 +648,72 @@ describe("runRunnerInteractiveOAuth", () => { } }); + it("cancels a stalled server.start() on SIGINT instead of blocking", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = { + // Never resolves: a callback server stalled on listen(). + start: vi.fn(() => new Promise(() => {})), + stop: vi.fn(async () => {}), + } as unknown as OAuthCallbackServer; + const client = mockClient(); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + handleSignals: true, + }); + await Promise.resolve(); + process.emit("SIGINT", "SIGINT"); + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGINT).", + ); + expect(mockServer.stop).toHaveBeenCalled(); + expect(process.listenerCount("SIGINT")).toBe(0); + }); + + it("cancels a stalled authenticate() on SIGTERM, never reporting already_authorized", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + let releaseAuthenticate!: () => void; + const authGate = new Promise( + (resolve) => (releaseAuthenticate = resolve), + ); + const client = mockClient({ + // Resolves undefined — but only after the signal has already fired; + // the cancellation must win, not be discarded as already_authorized. + authenticate: vi.fn(async () => { + await authGate; + return undefined; + }), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + handleSignals: true, + }); + await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalled()); + process.emit("SIGTERM", "SIGTERM"); + releaseAuthenticate(); + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGTERM).", + ); + expect(mockServer.stop).toHaveBeenCalled(); + expect(process.listenerCount("SIGTERM")).toBe(0); + }); + it("installs no signal listeners unless handleSignals is set (TUI owns Ctrl-C via Ink)", async () => { const redirectUrlProvider = { redirectUrl: "" }; const mockServer = createMockCallbackServer(handlers); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index b87c586c9a..e83626018e 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -101,8 +101,31 @@ export async function runRunnerInteractiveOAuth( // AUTH_REQUIRED — see clients/cli/src/error-handler.ts) rather than a raw // process death. Opt-in (see handleSignals) — never installed under the // TUI, which owns Ctrl-C through Ink. + // + // Every awaited phase — server.start(), authenticate() / + // beginInteractiveAuthorization(), the callback wait, and the challenge + // check — is raced against `signalAbort`: rejecting only flowDone would + // leave a signal during a stalled startup or authorization ignored until + // the final callback wait (and discarded entirely when authenticate() + // resolves undefined). + let signalAbortReject!: (err: Error) => void; + const signalAbort = new Promise((_, reject) => { + signalAbortReject = reject; + }); + // Same pre-subscription window as flowDone above. + // void: intentional fire-and-forget rejection observer + void signalAbort.catch(() => {}); const onSignal = (signal: NodeJS.Signals) => { - flowReject(new Error(`OAuth authorization cancelled (${signal}).`)); + const err = new Error(`OAuth authorization cancelled (${signal}).`); + signalAbortReject(err); + flowReject(err); + }; + const racingSignals = (work: Promise): Promise => { + if (!options.handleSignals) return work; + // If the signal wins the race, `work` is abandoned while still pending; + // observe its eventual rejection so it can't surface as unhandled. + void work.catch(() => {}); + return Promise.race([work, signalAbort]); }; if (options.handleSignals) { process.on("SIGINT", onSignal); @@ -112,27 +135,29 @@ export async function runRunnerInteractiveOAuth( let timeoutId: ReturnType | undefined; try { - const { redirectUrl } = await server.start({ - hostname: options.callbackListen.hostname, - port: options.callbackListen.port, - path: options.callbackListen.pathname, - onCallback: async (params) => { - try { - await options.client.completeOAuthFlow(params.code, params.iss); - flowResolve(); - } catch (err) { - flowReject(toRunnerOAuthError(err)); - } - }, - onError: (params) => { - flowReject( - new Error( - /* v8 ignore next -- params.error is a required non-null string, so the "OAuth error" fallback is unreachable */ - params.error_description ?? params.error ?? "OAuth error", - ), - ); - }, - }); + const { redirectUrl } = await racingSignals( + server.start({ + hostname: options.callbackListen.hostname, + port: options.callbackListen.port, + path: options.callbackListen.pathname, + onCallback: async (params) => { + try { + await options.client.completeOAuthFlow(params.code, params.iss); + flowResolve(); + } catch (err) { + flowReject(toRunnerOAuthError(err)); + } + }, + onError: (params) => { + flowReject( + new Error( + /* v8 ignore next -- params.error is a required non-null string, so the "OAuth error" fallback is unreachable */ + params.error_description ?? params.error ?? "OAuth error", + ), + ); + }, + }), + ); options.onCallbackServer?.(server); options.redirectUrlProvider.redirectUrl = redirectUrl; @@ -156,14 +181,20 @@ export async function runRunnerInteractiveOAuth( }, timeoutMs); }), ]); + // A signal can now abort the flow between this construction and the + // point waitForCallback is awaited (flowDone rejects but the racing + // authenticate()/beginInteractiveAuthorization() throws first); observe + // the rejection so that path can't surface it as unhandled. + // void: intentional fire-and-forget rejection observer + void waitForCallback.catch(() => {}); if (options.authorizationUrl) { - await options.client.beginInteractiveAuthorization( - options.authorizationUrl, + await racingSignals( + options.client.beginInteractiveAuthorization(options.authorizationUrl), ); await waitForCallback; } else { - const authUrl = await options.client.authenticate(); + const authUrl = await racingSignals(options.client.authenticate()); if (authUrl !== undefined) { await waitForCallback; } else { @@ -172,8 +203,8 @@ export async function runRunnerInteractiveOAuth( } if (options.authChallenge) { - const satisfied = await options.client.checkAuthChallengeSatisfied( - options.authChallenge, + const satisfied = await racingSignals( + options.client.checkAuthChallengeSatisfied(options.authChallenge), ); if (!satisfied) { return { From db5e7abf7494217f23651803bff98875d702d18e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 14:58:41 -0700 Subject: [PATCH 26/69] fix(daemon-cli): address Copilot review round 17 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: bound the shutdown flush — destroySoon() only destroys after queued bytes drain, so a client that stopped reading with a buffered response could hang daemon stop/SIGTERM forever; server.close() now races a flushTimeoutMs grace (default 2s) that force-destroys any remaining sockets - parse-tool-args: reject values that cannot round-trip through JSON — JSON.parse accepts 1e999 (Infinity), which NDJSON serialization would silently send to the tool as null; recursive validation applied to key:=value pairs, inline JSON objects, --tool-args-json, and the --tool-arg/--tool-metadata key=value paths - pack-and-verify: exercise the packaged daemon artifact — connect to the stdio fixture (spawning the shipped build/daemon.js), verify connections/list, disconnect, and stop the daemon before any failure exit so nothing outlives the check Regression tests: force-destroy on a never-draining flush (paused client with 4 MiB buffered), non-finite rejection across all argument styles with a finite 1e308 control. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-coverage.test.ts | 24 ++++++++ .../__tests__/parse-tool-args.test.ts | 27 +++++++++ clients/daemon-cli/src/connection/mcp.ts | 6 +- .../src/connection/parse-tool-args.ts | 33 ++++++++++- clients/daemon-cli/src/daemon/server.ts | 27 ++++++++- scripts/pack-and-verify.mjs | 59 +++++++++++++++++++ 6 files changed, 171 insertions(+), 5 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 6322d0bacf..710e6c87c7 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -188,6 +188,30 @@ describe("daemon coverage", () => { expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false); }); + it("stop() force-destroys sockets whose shutdown flush never drains", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0, flushTimeoutMs: 100 }); + await server.start(); + const client = net.connect(server.socketPath); + await new Promise((resolve) => client.on("connect", () => resolve())); + client.pause(); + const ipcSockets = (server as unknown as { ipcSockets: Set }) + .ipcSockets; + await vi.waitFor(() => expect(ipcSockets.size).toBe(1)); + // Backpressure: buffer a payload the paused client never reads, so + // destroySoon()'s drain never completes and server.close() would wait + // forever without the bounded force-destroy. + const [serverSocket] = ipcSockets; + serverSocket!.write("x".repeat(4 * 1024 * 1024)); + const stopped = await Promise.race([ + server.stop("stop").then(() => true), + new Promise((r) => setTimeout(() => r(false), 5000)), + ]); + expect(stopped).toBe(true); + server = undefined; + client.destroy(); + }); + it("removes a stale socket before binding", async () => { const d = freshDir(); const sock = path.join(d, "daemon.sock"); diff --git a/clients/daemon-cli/__tests__/parse-tool-args.test.ts b/clients/daemon-cli/__tests__/parse-tool-args.test.ts index 16529a0d85..8885c53897 100644 --- a/clients/daemon-cli/__tests__/parse-tool-args.test.ts +++ b/clients/daemon-cli/__tests__/parse-tool-args.test.ts @@ -107,6 +107,33 @@ describe("resolveToolCallArgs", () => { ).toThrow(/one style/); }); + it("rejects non-finite numbers that JSON cannot represent", () => { + // 1e999 parses to Infinity; NDJSON serialization would send null. + expect(() => parseToolCallPositionals(["count:=1e999"])).toThrow( + /no JSON representation/, + ); + expect(() => parseToolCallPositionals(["count:=-1e999"])).toThrow( + /no JSON representation/, + ); + expect(() => parseToolCallPositionals(['{"count":1e999}'])).toThrow( + /no JSON representation/, + ); + // Nested values are validated recursively. + expect(() => parseToolCallPositionals(['{"a":{"b":[1,2,1e999]}}'])).toThrow( + /no JSON representation/, + ); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: '{"count":1e999}', + }), + ).toThrow(/no JSON representation/); + // Large-but-finite numbers still round-trip and are accepted. + expect(parseToolCallPositionals(["count:=1e308"])).toEqual({ + count: 1e308, + }); + }); + it("rejects invalid --tool-args-json", () => { expect(() => resolveToolCallArgs({ diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 62d9b311d4..594c7aa212 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -43,7 +43,10 @@ import { } from "@inspector/cli/handlers/method-types.js"; import { authorizeInFrontend } from "./authorize.js"; import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; -import { resolveToolCallArgs } from "./parse-tool-args.js"; +import { + assertJsonRoundTrips, + resolveToolCallArgs, +} from "./parse-tool-args.js"; import { resolveCommandPath } from "./resolve-command.js"; import { dispatchConnectionRpc, @@ -1169,6 +1172,7 @@ function parseKeyValue( } catch { parsedValue = val; } + assertJsonRoundTrips(parsedValue, `parameter "${value}"`); return { ...previous, [key]: parsedValue }; } diff --git a/clients/daemon-cli/src/connection/parse-tool-args.ts b/clients/daemon-cli/src/connection/parse-tool-args.ts index 1c7829d587..809961d56f 100644 --- a/clients/daemon-cli/src/connection/parse-tool-args.ts +++ b/clients/daemon-cli/src/connection/parse-tool-args.ts @@ -1,5 +1,27 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; +/** + * `JSON.parse` accepts numbers JSON cannot represent (`1e999` → `Infinity`); + * the NDJSON serialization to the daemon would then silently send `null`, + * invoking the tool with a different value than the user typed. Reject + * anything that cannot round-trip instead. + */ +export function assertJsonRoundTrips(value: unknown, context: string): void { + if (typeof value === "number" && !Number.isFinite(value)) { + throw new Error( + `Invalid ${context}: ${value} has no JSON representation ` + + `(it would silently reach the tool as null).`, + ); + } + if (Array.isArray(value)) { + for (const item of value) assertJsonRoundTrips(item, context); + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) { + assertJsonRoundTrips(item, context); + } + } +} + /** * Parse connection `tools/call` positionals after the tool name: * - `key:=value` pairs (JSON-typed when the value parses as JSON, else string) @@ -33,6 +55,7 @@ export function parseToolCallPositionals( ) { throw new Error("Inline JSON tool arguments must be a JSON object."); } + assertJsonRoundTrips(parsed, "inline JSON tool arguments"); return parsed as Record; } @@ -55,17 +78,20 @@ export function parseToolCallPositionals( `Invalid tool argument "${pair}" — missing key before :=`, ); } - out[key] = autoParseValue(rawValue); + out[key] = autoParseValue(rawValue, `tool argument "${pair}"`); } return out; } -function autoParseValue(raw: string): JsonValue { +function autoParseValue(raw: string, context: string): JsonValue { + let parsed: unknown; try { - return JSON.parse(raw) as JsonValue; + parsed = JSON.parse(raw) as JsonValue; } catch { return raw; } + assertJsonRoundTrips(parsed, context); + return parsed as JsonValue; } export type ResolveToolCallArgsInput = { @@ -133,5 +159,6 @@ function parseJsonObject(raw: string, flag: string): Record { if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`${flag} must be a JSON object.`); } + assertJsonRoundTrips(parsed, flag); return parsed as Record; } diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index f76979eba0..af2b95110d 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -66,6 +66,12 @@ export type DaemonServerOptions = { requiredToken?: string; /** Called when the daemon should exit (idle timeout or daemon/stop). */ onShutdown?: () => void; + /** + * Grace period at shutdown for flushing buffered response bytes before + * still-open sockets are force-destroyed (a client that stopped reading + * must not hang `daemon stop`). Tests use a short value. + */ + flushTimeoutMs?: number; }; /** @@ -100,6 +106,8 @@ export class DaemonServer { this.socketPath = getDaemonSocketPath(this.dir); this.lockPath = getDaemonLockPath(this.dir); this.requiredToken = options.requiredToken ?? getDaemonTokenFromEnv(); + this.flushTimeoutMs = + options.flushTimeoutMs ?? DaemonServer.FLUSH_TIMEOUT_MS; this.registry = new ConnectionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); this.onShutdown = options.onShutdown ?? null; this.registry.setIdleHandler(() => { @@ -198,7 +206,20 @@ export class DaemonServer { resolve(); return; } - this.server.close(() => resolve()); + // destroySoon() only destroys once queued bytes drain — a client that + // stopped reading with a buffered response would keep server.close() + // waiting forever. Bounded grace for the flush, then force-destroy + // whatever is left. + const force = setTimeout(() => { + for (const socket of [...this.ipcSockets]) { + socket.destroy(); + } + }, this.flushTimeoutMs); + force.unref(); + this.server.close(() => { + clearTimeout(force); + resolve(); + }); }); this.server = null; this.removeLockAndSocket(); @@ -209,6 +230,10 @@ export class DaemonServer { * anyway. Exported for tests. */ static readonly QUIESCE_TIMEOUT_MS = 3_000; + /** Default shutdown flush grace before force-destroying sockets. */ + static readonly FLUSH_TIMEOUT_MS = 2_000; + private readonly flushTimeoutMs: number; + private waitForActiveOps(timeoutMs: number): Promise { if (this.activeOps === 0) return Promise.resolve(); return new Promise((resolve) => { diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 17b5b13e28..16ae121e5a 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -435,6 +435,65 @@ try { ); } + // 4b³. Daemon lifecycle from the installed package: `connect` must locate + // and spawn the separately shipped `build/daemon.js` — the daemon-free + // checks above pass even when that artifact is missing or mislocated, + // yet every connection command would fail at startup. Connect against + // the same stdio fixture, verify the connection is listed, then tear + // everything down (stop the daemon before any fail() so nothing + // outlives the check). + step("verifying installed `mcpdo` daemon flow (connect/list/disconnect)..."); + const mcpdoDaemonEnv = { + MCP_INSPECTOR_DAEMON_DIR: join(work, "mcpdo-daemon"), + }; + const stopMcpdoDaemon = () => runMcpdo(["daemon", "stop"], mcpdoDaemonEnv); + const failMcpdoDaemonFlow = (message) => { + stopMcpdoDaemon(); + fail(message); + }; + const mcpdoConnect = runMcpdo( + ["connect", "test", "--catalog", catalogPath, "--plain"], + mcpdoDaemonEnv, + ); + if (mcpdoConnect.status !== 0 || !mcpdoConnect.output.includes("test")) { + failMcpdoDaemonFlow( + `\`mcpdo connect test\` exited ${mcpdoConnect.status} — the packaged ` + + `daemon (build/daemon.js) likely failed to start\n` + + mcpdoConnect.output.slice(0, 800), + ); + } + const mcpdoConnections = runMcpdo( + ["connections/list", "--plain"], + mcpdoDaemonEnv, + ); + if ( + mcpdoConnections.status !== 0 || + !mcpdoConnections.output.includes("test") + ) { + failMcpdoDaemonFlow( + `\`mcpdo connections/list\` exited ${mcpdoConnections.status} or missing ` + + `the "test" connection\n` + + mcpdoConnections.output.slice(0, 800), + ); + } + const mcpdoDisconnect = runMcpdo( + ["disconnect", "test", "--plain"], + mcpdoDaemonEnv, + ); + if (mcpdoDisconnect.status !== 0) { + failMcpdoDaemonFlow( + `\`mcpdo disconnect test\` exited ${mcpdoDisconnect.status}\n` + + mcpdoDisconnect.output.slice(0, 800), + ); + } + const mcpdoStop = stopMcpdoDaemon(); + if (mcpdoStop.status !== 0) { + fail( + `\`mcpdo daemon stop\` exited ${mcpdoStop.status}\n` + + mcpdoStop.output.slice(0, 800), + ); + } + // 4c. Prod `--web` boot from the installed package — THE critical packaging // path: the runner must locate and serve the shipped `dist` (not rebuild // it) and inject the auth token. Run non-blocking and poll `/`. From 4c1c96da0cda0563d431e444553ce8f8835e9068 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 15:26:09 -0700 Subject: [PATCH 27/69] fix(cli, spec): address Copilot review round 18 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli run-method: reject non-finite numbers in tasks/update --input-responses — JSON.parse accepts 1e999 as Infinity, which serialization for IPC/MCP would silently send as null; recursive round-trip validation mirrors the daemon-cli tool-argument parser - spec: add connections/show to the IPC op list; drop the stale "Windows daemon transport" to-do row (named pipes shipped) Regression test: nested non-finite input-responses value rejected. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 9 ++ clients/cli/src/handlers/run-method.ts | 20 +++ specification/v2_cli_v2.md | 125 +++++++++--------- 3 files changed, 91 insertions(+), 63 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index 222cf09020..6da96022b1 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -225,6 +225,15 @@ describe("runMethod (mocked client)", () => { inputResponsesJson: "[1,2,3]", }), ).rejects.toThrow(/--input-responses is invalid/); + // 1e999 parses as Infinity, which serialization would silently send as + // null — reject it instead of answering with a different value. + await expect( + runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: '{"a":{"b":[1e999]}}', + }), + ).rejects.toThrow(/no JSON representation/); await expect( runMethod(client, { diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index 1d30e5be51..2538c2421f 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -34,6 +34,25 @@ import type { * `resources/directory/read`, whose stricter `directoryRead` gate lives in * `InspectorClient` itself. */ +/** + * `JSON.parse` accepts numeric literals JSON cannot represent (`1e999` → + * `Infinity`); serializing the request for IPC/MCP would then silently send + * `null` instead of the value the user supplied. Reject anything that cannot + * round-trip. + */ +function assertJsonRoundTrips(value: unknown): void { + if (typeof value === "number" && !Number.isFinite(value)) { + throw new Error( + `${value} has no JSON representation (it would silently be sent as null)`, + ); + } + if (Array.isArray(value)) { + for (const item of value) assertJsonRoundTrips(item); + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) assertJsonRoundTrips(item); + } +} + function assertSkillsSupported( inspectorClient: InspectorClient, method: string, @@ -333,6 +352,7 @@ export async function runMethod( ) { throw new Error("must be a JSON object"); } + assertJsonRoundTrips(parsed); inputResponses = parsed as Record; } catch (e) { throw new Error( diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 03fbd5039c..83058d7cc2 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -12,12 +12,12 @@ Documentation of the **experimental** connection-oriented Inspector CLI (`mcpdo` ## Overview -| | **One-shot** | **Connection** | -| --- | --- | --- | -| Entrypoint | `mcp-inspector --cli` | `mcpdo` | -| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands → disconnect | -| Process | In-process only | Short-lived front-end + implicit connection daemon (IPC) | -| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/daemon-cli` (experimental; ships the `mcpdo` bin with `@modelcontextprotocol/inspector`) | +| | **One-shot** | **Connection** | +| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| Entrypoint | `mcp-inspector --cli` | `mcpdo` | +| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands → disconnect | +| Process | In-process only | Short-lived front-end + implicit connection daemon (IPC) | +| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/daemon-cli` (experimental; ships the `mcpdo` bin with `@modelcontextprotocol/inspector`) | Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (mcpdo reaches in via a temporary `@inspector/cli` build alias). One-shot never starts the daemon. `mcpdo` does not accept `--method`. @@ -45,12 +45,12 @@ mcpdo tools/list ### Entrypoints and layout -| Piece | Location | -| --- | --- | -| One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | +| Piece | Location | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | | Connection front-end | `clients/daemon-cli/src/connection/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`) + `mcp-bin.ts` | -| Daemon | `clients/daemon-cli/src/daemon/` → `clients/daemon-cli/build/daemon.js` | -| Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | +| Daemon | `clients/daemon-cli/src/daemon/` → `clients/daemon-cli/build/daemon.js` | +| Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | ``` mcp-inspector --cli … mcpdo … @@ -69,10 +69,10 @@ mcp-inspector --cli … mcpdo … Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the connection daemon. -| `--method` | Notes | -| --- | --- | -| `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface (`ONE_SHOT_METHODS`) | -| `servers/list`, `servers/show` | Catalog only (no MCP connect); `servers/show` needs `--server` | +| `--method` | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface (`ONE_SHOT_METHODS`) | +| `servers/list`, `servers/show` | Catalog only (no MCP connect); `servers/show` needs `--server` | Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) is a **usage error before connect** — one-shot must not hang on stream outcomes. @@ -84,13 +84,13 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) #### Commands -| Category | Commands | -| --- | --- | -| Catalog | `servers/list`, `servers/show ` | -| Connection | `connect` (`--relogin`), `disconnect`, `connections/list`, `connections/use` | -| Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | -| Daemon | `private`, `daemon status`, `daemon stop` | -| MCP | `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` (`initialize` is deliberately not registered — connection metadata comes from `connections/show`) | +| Category | Commands | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Catalog | `servers/list`, `servers/show ` | +| Connection | `connect` (`--relogin`), `disconnect`, `connections/list`, `connections/use` | +| Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | +| Daemon | `private`, `daemon status`, `daemon stop` | +| MCP | `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` (`initialize` is deliberately not registered — connection metadata comes from `connections/show`) | **Globals (before subcommand):** `--format text|json`, `--plain`, `--connection `, `--catalog` / `--config`, `--stored-auth-only`. @@ -100,11 +100,11 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) #### Output -| Flag | Behaviour | -| --- | --- | +| Flag | Behaviour | +| ------------------------- | ----------------------------------------------------------------------------------------------- | | `--format text` (default) | Human-readable. On a TTY: ANSI color / bold / dim / OSC 8 links unless `--plain` or `NO_COLOR`. | -| `--format json` | Pretty-printed payload (**no** `{ result }` envelope; never ANSI). | -| Streams | Long-lived until Ctrl-C; human lines or pretty JSON events per `--format`. | +| `--format json` | Pretty-printed payload (**no** `{ result }` envelope; never ANSI). | +| Streams | Long-lived until Ctrl-C; human lines or pretty JSON events per `--format`. | #### Default connection (MRU) @@ -115,23 +115,23 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) #### Daemon -**IPC ops:** `ping`, `connect`, `disconnect`, `connections/list`, `connections/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. +**IPC ops:** `ping`, `connect`, `disconnect`, `connections/list`, `connections/use`, `connections/show`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. - One `InspectorClient` per named connection; auto-spawn on first need; idle exit ~60s after last disconnect **or** after a connection-less spawn with no successful connect; `daemon stop` tears down immediately. - Socket/lock mode `0600` (best-effort). Config (incl. secrets) over IPC after listen — not on daemon argv. - Errors that are not already `CliExitCodeError` go through `classifyError` (exit-code parity with one-shot). -| Context | Path | -| --- | --- | -| Shared default | `~/.mcp-inspector/daemon.sock` (+ `daemon.lock`, `daemon.token`, `daemon.log`) | -| `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | -| `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | -| Private | `$TMPDIR/mcp-conn-//` (0700, short id — `sun_path` caps socket paths at 104 bytes on macOS) from `mcpdo private` | +| Context | Path | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Shared default | `~/.mcp-inspector/daemon.sock` (+ `daemon.lock`, `daemon.token`, `daemon.log`) | +| `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | +| `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | +| Private | `$TMPDIR/mcp-conn-//` (0700, short id — `sun_path` caps socket paths at 104 bytes on macOS) from `mcpdo private` | -| Mode | Trust | -| --- | --- | -| **Shared (default)** | Auto-generated token, published to `daemon.token` (0600) in the daemon dir (0700). Same-UID peer that can read the dir can drive connections (intentional cross-terminal share); there is no unauthenticated request path. | -| **Private** | `eval "$(mcpdo private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | +| Mode | Trust | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Shared (default)** | Auto-generated token, published to `daemon.token` (0600) in the daemon dir (0700). Same-UID peer that can read the dir can drive connections (intentional cross-terminal share); there is no unauthenticated request path. | +| **Private** | `eval "$(mcpdo private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | #### Auth (connection) @@ -143,18 +143,18 @@ Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) #### One-shot ↔ connection mapping -| One-shot | Connection | -| --- | --- | -| `… --catalog mcp.json --server s --method tools/list` | `mcpdo connect --catalog mcp.json s` then `mcpdo tools/list` | -| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpdo tools/call X key:=val` / `'{"…"}'` | -| `… --method servers/list` | `mcpdo servers/list` | -| `… --method servers/show --server ` | `mcpdo servers/show ` | +| One-shot | Connection | +| ---------------------------------------------------------- | ------------------------------------------------------------ | +| `… --catalog mcp.json --server s --method tools/list` | `mcpdo connect --catalog mcp.json s` then `mcpdo tools/list` | +| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpdo tools/call X key:=val` / `'{"…"}'` | +| `… --method servers/list` | `mcpdo servers/list` | +| `… --method servers/show --server ` | `mcpdo servers/show ` | ### Testing -| Client | Runner | Coverage | -| --- | --- | --- | -| One-shot (`clients/cli`) | In-process `runCli()`; thin binary e2e | Per-file ≥90 on `clients/cli/src`. Exclusion: `src/index.ts`. | +| Client | Runner | Coverage | +| ------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| One-shot (`clients/cli`) | In-process `runCli()`; thin binary e2e | Per-file ≥90 on `clients/cli/src`. Exclusion: `src/index.ts`. | | Connection CLI (`clients/daemon-cli`) | In-process `runMcp()`; daemon IPC + stream + private-token tests | Per-file ≥90 on `clients/daemon-cli/src`. Exclusions: `mcp-bin.ts`, `daemon/run.ts` (bootstraps only). | Both are wired into root `validate` / `coverage`. @@ -163,20 +163,19 @@ Both are wired into root `validate` / `coverage`. ## To-do -| Item | Notes | -| --- | --- | -| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpdo` during `rpc`/`stream`. Connect-time only today. | -| **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | -| **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | -| **Per-connection RPC mutex** | Parallel `mcpdo` processes against one connection can interleave on one `InspectorClient`. | -| **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | -| **Split `registerRpcCommands`** | Large Commander switch in `connection/mcp.ts`. | -| **`mcpdo daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | -| **Launcher help polish** | Make `mcpdo` vs `--cli` unmistakable in launcher `--help` / docs. | -| **Connection `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; connection authorize uses defaults / env only. | -| **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | -| **Stream fan-out / `mcpdo attach`** | One consumer per stream invocation today. | -| **Sampling CLI** | Still TUI/web. mcpdo handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | -| **Ephemeral no-`connect` shortcuts on `mcpdo`** | Out of scope (keep two mental models). | -| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_CONNECTION=1`. | -| **Human `--full` schema dumps** | Optional formatter polish. | +| Item | Notes | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpdo` during `rpc`/`stream`. Connect-time only today. | +| **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | +| **Per-connection RPC mutex** | Parallel `mcpdo` processes against one connection can interleave on one `InspectorClient`. | +| **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | +| **Split `registerRpcCommands`** | Large Commander switch in `connection/mcp.ts`. | +| **`mcpdo daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | +| **Launcher help polish** | Make `mcpdo` vs `--cli` unmistakable in launcher `--help` / docs. | +| **Connection `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; connection authorize uses defaults / env only. | +| **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | +| **Stream fan-out / `mcpdo attach`** | One consumer per stream invocation today. | +| **Sampling CLI** | Still TUI/web. mcpdo handles server-driven _elicitation_ (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | +| **Ephemeral no-`connect` shortcuts on `mcpdo`** | Out of scope (keep two mental models). | +| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_CONNECTION=1`. | +| **Human `--full` schema dumps** | Optional formatter polish. | From 9e7e8ad783fcabf5b1a1dea3829c16fdb9402ff5 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 17:02:50 -0700 Subject: [PATCH 28/69] fix(daemon-cli): address Copilot review round 19 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make build:dev cross-platform: the POSIX-only `;` sequencing and /dev/null redirect (invalid under cmd.exe) move into a small stop-dev-daemon.mjs pre-build script that best-effort stops a resident daemon via execFileSync with stdio ignored — preserving the first-build behavior a plain && would break (no build/ yet must not skip tsup) Verified: build:dev succeeds, and the pre-build script exits 0 with no build present. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/daemon-cli/package.json | 2 +- clients/daemon-cli/scripts/stop-dev-daemon.mjs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 clients/daemon-cli/scripts/stop-dev-daemon.mjs diff --git a/clients/daemon-cli/package.json b/clients/daemon-cli/package.json index a91fc9e2a1..ad19aa5195 100644 --- a/clients/daemon-cli/package.json +++ b/clients/daemon-cli/package.json @@ -14,7 +14,7 @@ ], "scripts": { "build": "tsup", - "build:dev": "node build/mcp-bin.js daemon stop >/dev/null 2>&1; tsup", + "build:dev": "node scripts/stop-dev-daemon.mjs && tsup", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", "check": "npm run format:check && npm run lint && npm run typecheck", "validate": "npm run check && npm run test", diff --git a/clients/daemon-cli/scripts/stop-dev-daemon.mjs b/clients/daemon-cli/scripts/stop-dev-daemon.mjs new file mode 100644 index 0000000000..e5d8d56070 --- /dev/null +++ b/clients/daemon-cli/scripts/stop-dev-daemon.mjs @@ -0,0 +1,18 @@ +/** + * Pre-build step for `build:dev`: stop a daemon still running from a previous + * build so the fresh bundle isn't shadowed by a stale resident process. + * + * Best-effort by design — a missing `build/` (first build) or no running + * daemon must not fail the build. Kept as a script rather than shell syntax + * so the npm script works on Windows too (cmd.exe has no `;` sequencing or + * `/dev/null`). + */ +import { execFileSync } from "node:child_process"; + +try { + execFileSync(process.execPath, ["build/mcp-bin.js", "daemon", "stop"], { + stdio: "ignore", + }); +} catch { + // Nothing to stop (or nothing built yet) — proceed with the build. +} From 87945d7252f0c2b160e5370f9b277d22c4a0497a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 18:23:02 -0700 Subject: [PATCH 29/69] fix(daemon-cli, cli): address Copilot review round 20 on #1783 - Propagate async backpressure through daemon stream output: onData may now return a promise, and streamDaemon pauses socket reads until it settles, so a fast logging/resource stream can no longer queue unbounded pending stdout writes against a slow consumer. dispatchConnectionRpc returns its write chain from onData. - Reject --ema for ad-hoc connect targets up front with actionable guidance: EMA requires per-server oauth.clientId/clientSecret that only a catalog entry can supply, so the flow could never succeed. Help text and withEmaOverride docs updated to match. - Filter resources/subscribe stream events by the subscribed URI so multiple subscribe streams on one named connection no longer cross-talk; events without a uri still pass through. - Cover clients/daemon-cli/scripts with the prettier format globs (verify:format-coverage guard flagged round 19's stop-dev-daemon.mjs). - Tests: backpressure pause/resume, rejected write promise stays non-fatal, --ema ad-hoc rejection, URI cross-talk filtering. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 13 ++- clients/cli/src/handlers/run-method.ts | 4 + .../__tests__/daemon-stream.test.ts | 105 +++++++++++++++++- .../daemon-cli/__tests__/mcp-coverage.test.ts | 10 +- clients/daemon-cli/package.json | 4 +- clients/daemon-cli/src/connection/dispatch.ts | 3 + clients/daemon-cli/src/connection/mcp.ts | 32 ++++-- .../daemon-cli/src/daemon/stream-client.ts | 28 ++++- 8 files changed, 181 insertions(+), 18 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index 6da96022b1..c5f047df0d 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -105,11 +105,14 @@ describe("runMethod (mocked client)", () => { listener?.( new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }), ); - expect( - lines.some( - (l) => (l as { type?: string }).type === "resources/updated", - ), - ).toBe(true); + // Updates for other URIs on the same connection are filtered out. + listener?.( + new CustomEvent("resourceUpdated", { detail: { uri: "test://other" } }), + ); + const updated = lines.filter( + (l) => (l as { type?: string }).type === "resources/updated", + ); + expect(updated).toEqual([{ type: "resources/updated", uri: "test://x" }]); stop(); } diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index 2538c2421f..bbf51ed557 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -218,6 +218,10 @@ export async function runMethod( writeLine({ type: "subscribed", uri: args.uri }); const onUpdate = (ev: Event) => { const detail = (ev as CustomEvent<{ uri: string }>).detail; + // Multiple subscribe streams can share one connection; only + // forward updates for this stream's URI. Events without a uri + // (spec-noncompliant server) still pass through as before. + if (detail?.uri !== undefined && detail.uri !== args.uri) return; writeLine({ type: "resources/updated", uri: detail?.uri ?? args.uri, diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index d59d76ffb5..5d5fa34411 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -80,11 +80,114 @@ describe("streamDaemon + ipc-glue", () => { const data: unknown[] = []; await streamDaemon( { method: "logging/tail" }, - { socketPath: sock, timeoutMs: 5000, onData: (d) => data.push(d) }, + { + socketPath: sock, + timeoutMs: 5000, + onData: (d) => { + data.push(d); + }, + }, ); expect(data).toEqual([{ n: 1 }]); }); + it("pauses socket reads while an async onData callback is pending", async () => { + const sock = freshSock(); + let serverSocket: net.Socket | undefined; + let requestId: string | undefined; + await listen(sock, (socket) => { + serverSocket = socket; + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + requestId = req.id; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n", + ); + }); + }); + + const until = async (cond: () => boolean) => { + const deadline = Date.now() + 3000; + while (!cond()) { + if (Date.now() > deadline) throw new Error("condition timed out"); + await new Promise((r) => setTimeout(r, 5)); + } + }; + + const seen: unknown[] = []; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const done = streamDaemon( + { method: "logging/tail" }, + { + socketPath: sock, + timeoutMs: 5000, + // First callback stalls on the gate; the client must stop reading + // instead of queueing further frames behind an unbounded chain. + onData: (d) => { + seen.push(d); + return seen.length === 1 ? gate : undefined; + }, + }, + ); + + await until(() => seen.length === 1); + // Send a second frame + end while the first callback is still pending. + serverSocket!.write( + JSON.stringify({ id: requestId, stream: "data", data: { n: 2 } }) + "\n", + ); + serverSocket!.write( + JSON.stringify({ id: requestId, stream: "end" }) + "\n", + ); + await new Promise((r) => setTimeout(r, 100)); + // Reads are paused, so the second frame must not have been dispatched. + expect(seen.length).toBe(1); + + release(); + await done; + expect(seen).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it("continues the stream when an async onData callback rejects", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "data", data: { n: 2 } }) + "\n", + ); + socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n"); + }); + }); + + const seen: unknown[] = []; + // Write errors are non-fatal: a rejected callback promise must not kill + // the stream or surface as an unhandled rejection. + await streamDaemon( + { method: "logging/tail" }, + { + socketPath: sock, + timeoutMs: 5000, + onData: (d) => { + seen.push(d); + return Promise.reject(new Error("write failed")); + }, + }, + ); + expect(seen).toEqual([{ n: 1 }, { n: 2 }]); + }); + it("rejects on socket error after the stream has opened", async () => { const sock = freshSock(); await listen(sock, (socket) => { diff --git a/clients/daemon-cli/__tests__/mcp-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-coverage.test.ts index 40fdd96335..4f48c50394 100644 --- a/clients/daemon-cli/__tests__/mcp-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-coverage.test.ts @@ -337,7 +337,6 @@ describe("mcp.ts coverage", () => { "auto", "--elicit", "url", - "--ema", "--format", "json", command, @@ -347,6 +346,15 @@ describe("mcp.ts coverage", () => { ); expectCliSuccess(adHoc); + // --ema is rejected for ad-hoc targets: EMA needs per-server OAuth + // client id/secret, which only a catalog entry can supply. + const emaAdHoc = await runMcp( + ["connect", "--ema", "--format", "json", command, ...args], + { env: e, timeout: 20000 }, + ); + expectCliFailure(emaAdHoc); + expect(emaAdHoc.stderr).toMatch(/--ema cannot be used with an ad-hoc/); + // Invalid --era is rejected before any connection is attempted. const badEra = await runMcp( ["connect", "--era", "bogus", "--format", "json", command, ...args], diff --git a/clients/daemon-cli/package.json b/clients/daemon-cli/package.json index ad19aa5195..64af0e72fc 100644 --- a/clients/daemon-cli/package.json +++ b/clients/daemon-cli/package.json @@ -24,8 +24,8 @@ "test-servers:build": "tsc -p ../../test-servers --noCheck", "pretest": "npm run test-servers:build && npm run build", "lint": "eslint . --max-warnings 0", - "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", - "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" + "format": "prettier --write src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", + "format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "devDependencies": { "@types/express": "^5.0.6", diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index 8c6891f093..1b4ca1ac2b 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -73,6 +73,9 @@ export async function dispatchConnectionRpc( // stream is still running; write errors stay non-fatal, as they // were when these writes were fire-and-forget. writeChain.catch(() => {}); + // Returning the chain lets streamDaemon pause socket reads until + // the write settles, bounding memory when stdout is slow. + return writeChain; }, }); } finally { diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 594c7aa212..0aeb45af4e 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -345,10 +345,10 @@ function registerConnect(program: CommandType): void { "--ema", "Treat the server as enterprise-managed (EMA): mint tokens from the " + "signed-in enterprise IdP session instead of standard OAuth. " + - "Overrides the catalog/config entry's oauth.enterpriseManaged; the " + - "only way to set it for an ad-hoc target. Requires install-level IdP " + - "config (see auth/ema-status) and per-server OAuth client id/secret " + - "from the catalog entry.", + "Overrides the catalog/config entry's oauth.enterpriseManaged. " + + "Requires install-level IdP config (see auth/ema-status) and " + + "per-server OAuth client id/secret from the catalog entry, so it " + + "cannot be used with an ad-hoc target.", ) .action(async (target: string[], cmdOpts) => { const opts = program.opts(); @@ -384,6 +384,22 @@ function registerConnect(program: CommandType): void { (rest.length === 1 && (looksLikeUrl(rest[0]!) || looksLikePath(rest[0]!))); + // EMA needs the resource server's OAuth client id/secret, which only a + // catalog/config entry can carry (oauth.clientId / oauth.clientSecret). + // An ad-hoc target has no entry and this CLI deliberately offers no + // secret-bearing flags, so the flow would only fail later with an + // opaque error — reject up front with actionable guidance instead. + if (cmdOpts.ema === true && adHoc) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "--ema cannot be used with an ad-hoc target: EMA requires per-server " + + "OAuth client id/secret from a catalog entry. Add the server to a " + + "catalog with oauth.clientId and oauth.clientSecret (and " + + "oauth.enterpriseManaged), then connect by entry name.", + { code: "usage" }, + ); + } + const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH; const serverOptions = { catalogPath: opts.catalog?.trim() || envCatalog, @@ -1128,10 +1144,12 @@ function withElicitOverride( } /** - * Overlay `--ema` onto the settings lifted from the file/ad-hoc target. + * Overlay `--ema` onto the settings lifted from the catalog/config target. * Mirrors `withEraOverride`: only `enterpriseManaged` is overridden, and a - * bare-defaults settings object is synthesized when the target had none (the - * common ad-hoc case, which otherwise has no way to request EMA). + * bare-defaults settings object is synthesized when the entry had none. + * Ad-hoc targets never reach here with `--ema` set — connect rejects that + * combination up front, since EMA needs per-server OAuth credentials only a + * catalog entry can supply. */ function withEmaOverride( settings: InspectorServerSettings | undefined, diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index 15fd6ab787..cd909ad6a2 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -17,7 +17,12 @@ import { daemonTokenDir } from "./client.js"; import { sanitizeText } from "../connection/sanitize.js"; export type StreamDaemonOptions = DaemonClientOptions & { - onData: (data: unknown) => void; + /** + * Called for every data frame. A returned promise applies backpressure: + * socket reads pause until it settles, so a fast daemon stream cannot + * queue unbounded output ahead of a slow consumer. + */ + onData: (data: unknown) => void | Promise; /** Abort / cancel the stream (closes the socket). */ signal?: AbortSignal; }; @@ -44,6 +49,7 @@ export async function streamDaemon( let settled = false; let buffer = ""; let streaming = false; + let pendingCallbacks = 0; let timer: ReturnType | undefined; const socket = new net.Socket(); @@ -115,7 +121,25 @@ export async function streamDaemon( } if (frame.id !== id) return; if (frame.stream === "data") { - options.onData(frame.data); + const result = options.onData(frame.data); + if ( + result !== undefined && + typeof (result as Promise).then === "function" + ) { + // Backpressure: stop reading until the consumer's write settles. + // Frames already split from the current chunk still dispatch + // synchronously (bounded by one socket read), but no further + // chunks are read while any callback is pending. Callback errors + // stay non-fatal, matching the previous fire-and-forget behavior. + pendingCallbacks++; + socket.pause(); + void Promise.resolve(result) + .catch(() => {}) + .finally(() => { + pendingCallbacks--; + if (pendingCallbacks === 0 && !settled) socket.resume(); + }); + } return; } if (frame.stream === "end") { From f87360f534c6c81a5453178b2d629684bd89f6f9 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 19:00:47 -0700 Subject: [PATCH 30/69] fix(daemon-cli, cli): address Copilot review round 21 on #1783 - Recover the stream output write chain after a rejected write so one failed stdout write no longer silently drops every later event. - Serialize rpc ops per client in the daemon so concurrent RPCs on one connection cannot misroute an elicitation prompt to the wrong caller's terminal; bridge docs updated. - Reference-count same-URI resources/subscribe streams: only the first consumer subscribes and only the last stream's close unsubscribes, so closing one stream no longer silences its same-URI sibling. The final unsubscribe's rejection is caught at the source (it can fire after daemon disconnectAll during shutdown). - Preserve form-prompt schema properties named __proto__ by building the accepted payload with a null prototype. - Dispose an opened stream outcome when the caller's socket died while the handler ran, so resources/subscribe cannot leak a hidden daemon-side subscription with no consumer. - Tie daemon streams to their connection's lifecycle: a producer-side endStream channel plus a statusChange listener ends logging/tail and subscribe streams when their named connection disconnects or fails, instead of hanging until Ctrl-C or daemon idle shutdown. - Tests: write-chain recovery, per-connection rpc serialization, subscribe refcounting + swallowed unsubscribe rejection, __proto__ field preservation, mid-handle disposal, producer-side end, stream termination on disconnect. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 42 ++++++++ clients/cli/src/handlers/run-method.ts | 35 ++++++- .../__tests__/daemon-connections.test.ts | 97 +++++++++++++++++++ .../__tests__/daemon-ipc-glue.test.ts | 68 ++++++++++++- clients/daemon-cli/__tests__/dispatch.test.ts | 25 +++++ .../daemon-cli/__tests__/form-prompt.test.ts | 20 ++++ clients/daemon-cli/src/connection/dispatch.ts | 29 +++--- .../daemon-cli/src/connection/form-prompt.ts | 9 +- .../src/daemon/elicitation-bridge.ts | 9 +- clients/daemon-cli/src/daemon/ipc-glue.ts | 45 +++++++-- clients/daemon-cli/src/daemon/server.ts | 50 +++++++++- 11 files changed, 398 insertions(+), 31 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index c5f047df0d..0b0b89fdfb 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -66,6 +66,48 @@ vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => { }); describe("runMethod (mocked client)", () => { + it("reference-counts same-URI subscribe streams", async () => { + const client = mockClient(); + const s1 = await runMethod(client, { + method: "resources/subscribe", + uri: "test://x", + }); + const s2 = await runMethod(client, { + method: "resources/subscribe", + uri: "test://x", + }); + // Both streams share one core subscription. + expect(client.subscribeToResource).toHaveBeenCalledTimes(1); + expect(s1.kind).toBe("stream"); + expect(s2.kind).toBe("stream"); + if (s1.kind === "stream" && s2.kind === "stream") { + const stop1 = s1.start(() => {}); + const stop2 = s2.start(() => {}); + stop1(); + // The survivor keeps the subscription alive. + expect(client.unsubscribeFromResource).not.toHaveBeenCalled(); + stop2(); + expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1); + } + + // A rejected unsubscribe (e.g. after daemon disconnectAll) is caught at + // the source instead of surfacing as an unhandled rejection. + const failing = mockClient({ + unsubscribeFromResource: vi + .fn() + .mockRejectedValue(new Error("client closed")), + } as Partial); + const s3 = await runMethod(failing, { + method: "resources/subscribe", + uri: "test://y", + }); + if (s3.kind === "stream") { + s3.start(() => {})(); + } + await new Promise((resolve) => setImmediate(resolve)); + expect(failing.unsubscribeFromResource).toHaveBeenCalledTimes(1); + }); + it("covers subscribe stream, tasks, complete, and app-info call", async () => { const client = mockClient({ callTool: vi.fn().mockResolvedValue({ diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index bbf51ed557..0ed5f64df0 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -66,6 +66,15 @@ function assertSkillsSupported( } } +/** + * Live `resources/subscribe` stream consumers per client and URI. Streams + * for the same URI on one connection share a single core subscription + * (`subscribeToResource` is a no-op filter update when already subscribed), + * so the unsubscribe must be reference-counted: tearing it down when the + * first stream closes would leave the survivors open but silent. + */ +const resourceStreamRefs = new WeakMap>(); + /** * Run one MCP method against a connected {@link InspectorClient}. * Core method dispatch used by the CLI (and other Inspector Node runners). @@ -210,7 +219,19 @@ export async function runMethod( "URI is required for resources/subscribe. Use --uri to specify the resource URI.", ); } - await inspectorClient.subscribeToResource(args.uri); + let refs = resourceStreamRefs.get(inspectorClient); + if (!refs) { + refs = new Map(); + resourceStreamRefs.set(inspectorClient, refs); + } + const uri = args.uri; + const priorConsumers = refs.get(uri) ?? 0; + // Only the first consumer subscribes; the count is bumped after the + // subscribe succeeds so a failure leaves nothing to unwind. + if (priorConsumers === 0) { + await inspectorClient.subscribeToResource(uri); + } + refs.set(uri, priorConsumers + 1); return { kind: "stream", label: "resources/subscribe", @@ -230,7 +251,17 @@ export async function runMethod( inspectorClient.addEventListener("resourceUpdated", onUpdate); return () => { inspectorClient.removeEventListener("resourceUpdated", onUpdate); - void inspectorClient.unsubscribeFromResource(args.uri!); + const remaining = (refs.get(uri) ?? 1) - 1; + if (remaining > 0) { + refs.set(uri, remaining); + return; + } + refs.delete(uri); + // Catch the rejection here: this stop can run during daemon + // shutdown after disconnectAll has closed the client, where the + // unsubscribe rejects; a bare `void` would surface that as an + // unhandled rejection outside any caller's try/catch. + void inspectorClient.unsubscribeFromResource(uri).catch(() => {}); }; }, }; diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index e19ca06c97..03133e366e 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -696,4 +696,101 @@ describe("DaemonServer IPC", () => { { socketPath: server.socketPath }, ); }); + + it("ends an open stream when its connection disconnects", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-streamlife-")); + server = new DaemonServer({ dir, idleMs: 0 }); + const { command, args } = getTestMcpServerCommand(); + await server.registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }); + + const outcome = await server.handleOutcome({ + id: "st", + op: "stream", + params: { method: "logging/tail", name: "s" } as never, + }); + expect(outcome.response.ok).toBe(true); + expect(outcome.startStream).toBeDefined(); + let ended = 0; + const stop = outcome.startStream!( + () => {}, + () => { + ended += 1; + }, + ); + + // Disconnecting the named connection must terminate its streams instead + // of leaving the caller attached to a stale client until Ctrl-C. + await server.handle({ + id: "d", + op: "disconnect", + params: { name: "s" } as never, + }); + const deadline = Date.now() + 3000; + while (ended === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(ended).toBe(1); + stop(); + }); + + it("serializes rpc ops per connection so elicitation routing is exact", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpcqueue-")); + server = new DaemonServer({ dir, idleMs: 0 }); + const { command, args } = getTestMcpServerCommand(); + await server.registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }); + + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const order: string[] = []; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const spy = vi + .spyOn(InspectorClient.prototype, "readResource") + .mockImplementation(async (uri: string) => { + order.push(`start:${uri}`); + if (uri === "test://a") await gate; + order.push(`end:${uri}`); + return { result: { contents: [] } } as never; + }); + try { + const first = server.handle({ + id: "1", + op: "rpc", + params: { method: "resources/read", uri: "test://a", name: "s" }, + }); + const second = server.handle({ + id: "2", + op: "rpc", + params: { method: "resources/read", uri: "test://b", name: "s" }, + }); + const deadline = Date.now() + 3000; + while (!order.includes("start:test://a") && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + // The second rpc must not have started while the first is in flight. + expect(order).toEqual(["start:test://a"]); + release(); + const [r1, r2] = await Promise.all([first, second]); + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + expect(order).toEqual([ + "start:test://a", + "end:test://a", + "start:test://b", + "end:test://b", + ]); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts index 769a49b097..96d3231eb5 100644 --- a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts +++ b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts @@ -43,7 +43,10 @@ function accept( elicitation: ElicitationChannel, ) => Promise<{ response: { id: string; ok: true; result: unknown }; - startStream?: (writeData: (data: unknown) => void) => () => void; + startStream?: ( + writeData: (data: unknown) => void, + endStream: () => void, + ) => () => void; }>, ): FakeSocket { const socket = new FakeSocket(); @@ -200,6 +203,69 @@ describe("acceptDaemonConnection guards", () => { expect(socket.all).toBe(""); }); + it("disposes an opened stream when the socket died mid-handle", async () => { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let started = 0; + let stops = 0; + const socket = accept(async (request) => { + await gate; + return { + response: { id: request.id, ok: true, result: {} }, + // e.g. resources/subscribe: producer-side state exists before the + // starter runs; the glue must start it inert and stop it so the + // daemon doesn't keep a hidden subscription with no consumer. + startStream: (writeData, endStream) => { + started += 1; + // The inert writer/end are safe to call: nothing reaches the wire. + writeData({ n: 1 }); + endStream(); + return () => { + stops += 1; + }; + }, + }; + }); + + socket.pushLine(REQUEST); + socket.destroy(); + await until(() => socket.destroyed); + release(); + await until(() => stops === 1); + expect(started).toBe(1); + expect(socket.all).toBe(""); + }); + + it("ends the stream when the producer invokes endStream", async () => { + let end: () => void = () => {}; + let stops = 0; + const socket = accept(async (request) => ({ + response: { id: request.id, ok: true, result: {} }, + startStream: (writeData, endStream) => { + end = endStream; + writeData({ n: 1 }); + return () => { + stops += 1; + }; + }, + })); + + socket.pushLine(REQUEST); + await until(() => socket.all.includes('"stream":"data"')); + + end(); + await until(() => socket.all.includes('"stream":"end"')); + expect(stops).toBe(1); + + // A duplicate end (or a later close event) does not double-stop. + end(); + socket.emit("close"); + await new Promise((resolve) => setImmediate(resolve)); + expect(stops).toBe(1); + }); + it("cleans up a stream once on socket error and ignores late writes", async () => { let lateWrite: (data: unknown) => void = () => {}; let stops = 0; diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index 7f9719b643..e82bf16479 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -161,6 +161,31 @@ describe("dispatchConnectionRpc", () => { expect(stdout).toContain("test://two"); }); + it("recovers the write chain after a failed write and keeps streaming", async () => { + // Regression: one rejected write left the chain permanently rejected, so + // every later frame's `.then` was skipped and the stream went silent. + writeReject.value = true; + streamDaemon.mockImplementation( + async ( + _params: unknown, + opts: { onData: (d: unknown) => void | Promise }, + ) => { + await opts.onData({ type: "subscribed", uri: "test://failed" }); + writeReject.value = false; + await opts.onData({ type: "subscribed", uri: "test://recovered" }); + }, + ); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "logging/tail", + {}, + { requireExplicit: false, connection: "@s" }, + ); + expect(stdout).not.toContain("test://failed"); + expect(stdout).toContain("test://recovered"); + }); + it("keeps stream write failures non-fatal, as when they were fire-and-forget", async () => { writeReject.value = true; streamDaemon.mockImplementation( diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index b333bb712a..3148257a95 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -99,6 +99,26 @@ describe("promptForm", () => { expect(stderr).not.toContain("This field is required"); }); + it("preserves a schema property named __proto__ as an own property", async () => { + // On a plain object, `content["__proto__"] = v` hits the prototype + // setter instead of creating an own property, silently dropping the + // answer; the accepted payload is built with a null prototype. + const field: FormField = { + name: "__proto__", + required: true, + title: "Proto", + kind: "string", + }; + const rl = fakeRl(["value", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome.action).toBe("accept"); + const content = (outcome as { content: Record }).content; + expect(Object.prototype.hasOwnProperty.call(content, "__proto__")).toBe( + true, + ); + expect(content["__proto__"]).toBe("value"); + }); + it("lets minLength reject a blank required answer", async () => { const field: FormField = { ...stringField, minLength: 3 }; const rl = fakeRl(["", "abc", ""]); diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index 1b4ca1ac2b..f029255b30 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -60,19 +60,22 @@ export async function dispatchConnectionRpc( socketPath, signal: ac.signal, onData: (data) => { - writeChain = writeChain.then(() => - writeConnectionOutput( - { format, style }, - { - kind: "stream-event", - data, - }, - ), - ); - // Detached observer: prevents an unhandled rejection while the - // stream is still running; write errors stay non-fatal, as they - // were when these writes were fire-and-forget. - writeChain.catch(() => {}); + writeChain = writeChain + .then(() => + writeConnectionOutput( + { format, style }, + { + kind: "stream-event", + data, + }, + ), + ) + // Recover the chain itself, not just observe it: a rejected + // chain would skip every later `.then`, silently dropping all + // subsequent events after one failed write. Write errors stay + // non-fatal, as they were when these writes were + // fire-and-forget. + .catch(() => {}); // Returning the chain lets streamDaemon pause socket reads until // the write settles, bounding memory when stdout is slow. return writeChain; diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index 48bdd9c3aa..c2472e831d 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -255,7 +255,14 @@ export async function promptForm( ) ).trim(); if (answer === "") { - const content: Record = {}; + // Null prototype: a schema is entitled to a property named + // "__proto__", which on a plain object would hit the prototype + // setter instead of creating an own property, silently dropping the + // answer (mirrors sanitizeDeep's handling of untrusted keys). + const content: Record = Object.create(null) as Record< + string, + unknown + >; for (const field of fields) { const v = values.get(field.name); if (v !== undefined) content[field.name] = v; diff --git a/clients/daemon-cli/src/daemon/elicitation-bridge.ts b/clients/daemon-cli/src/daemon/elicitation-bridge.ts index 3de2940ffa..1d3269bc1e 100644 --- a/clients/daemon-cli/src/daemon/elicitation-bridge.ts +++ b/clients/daemon-cli/src/daemon/elicitation-bridge.ts @@ -19,10 +19,11 @@ import type { ElicitationRequestFrame } from "./protocol.js"; * otherwise each install their own `newPendingElicitation` listener, so one * server elicitation would be delivered to every active caller — duplicate * prompts and multiple `respond()` calls. One listener per client dispatches - * each event to exactly one active subscriber. Core cannot attribute an - * elicitation to a specific in-flight call, so the oldest active subscriber - * is chosen (with core's one-pending-at-a-time guarantee the sets coincide - * for the common single-RPC case). + * each event to exactly one active subscriber. The daemon serializes `rpc` + * ops per client (see `DaemonServer.rpcQueues`), so at most one subscriber + * is active at a time and the dispatch is exact; the subscriber list (with + * its oldest-first pick) remains as defense in depth should that + * serialization ever change. */ type BridgeSubscriber = { channel: ElicitationChannel; requestId: string }; diff --git a/clients/daemon-cli/src/daemon/ipc-glue.ts b/clients/daemon-cli/src/daemon/ipc-glue.ts index 7363409626..ad33f9df1f 100644 --- a/clients/daemon-cli/src/daemon/ipc-glue.ts +++ b/clients/daemon-cli/src/daemon/ipc-glue.ts @@ -13,7 +13,15 @@ import type { ElicitationResponseFrame, } from "./protocol.js"; -export type StreamStarter = (writeData: (data: unknown) => void) => () => void; +/** + * Starts a stream producer. `writeData` pushes one data frame; `endStream` + * lets the producer side finish the stream cleanly (end frame + socket end), + * e.g. when the underlying connection is torn down. Returns an unsubscribe. + */ +export type StreamStarter = ( + writeData: (data: unknown) => void, + endStream: () => void, +) => () => void; /** Result of handling one daemon request — optional long-lived stream. */ export type HandleOutcome = { @@ -178,7 +186,25 @@ export function acceptDaemonConnection( return; } const outcome = await handle(request, elicitationChannel); - if (socket.destroyed) return; + if (socket.destroyed) { + // The caller vanished while the handler ran. A stream outcome may + // already hold producer-side state (resources/subscribe subscribes + // before returning its starter), so start it inert and stop it + // immediately — otherwise the daemon keeps a hidden subscription + // with no consumer. + if (outcome.response.ok && outcome.startStream) { + try { + const stop = outcome.startStream( + () => {}, + () => {}, + ); + stop(); + } catch { + // ignore cleanup errors + } + } + return; + } socket.write(encodeResponse(outcome.response)); if (!outcome.response.ok || !outcome.startStream) { @@ -208,13 +234,6 @@ export function acceptDaemonConnection( socket.destroy(); } }; - stop = outcome.startStream(writeData); - if (stopped) { - // Overflow hit while startStream was still running (synchronous - // producer): `stop` wasn't assigned yet, unsubscribe it now. - stopProducer(); - return; - } const cleanup = () => { if (stopped) return; stopped = true; @@ -225,6 +244,14 @@ export function acceptDaemonConnection( socket.end(); } }; + stop = outcome.startStream(writeData, cleanup); + if (stopped) { + // Overflow or a producer-side end hit while startStream was still + // running (synchronous producer): `stop` wasn't assigned yet, + // unsubscribe it now. + stopProducer(); + return; + } socket.once("close", cleanup); socket.once("error", cleanup); })(); diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index af2b95110d..b622661aed 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -15,6 +15,10 @@ import { } from "./ipc-glue.js"; import { wireElicitationBridge } from "./elicitation-bridge.js"; import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { isTerminalStatus } from "@inspector/core/mcp/types.js"; +import type { InspectorClientEventMap } from "@inspector/core/mcp/inspectorClientEventTarget.js"; +import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; import { assertSocketPathWithinLimit, ensureDaemonDir, @@ -100,6 +104,11 @@ export class DaemonServer { * so shutdown flushes and destroys them — otherwise server.close() would * wait forever. */ private readonly ipcSockets = new Set(); + /** Serializes `rpc` ops per client. Core cannot attribute an elicitation + * to a specific in-flight call, so with concurrent RPCs on one connection + * the bridge would route a prompt to the wrong caller's terminal; running + * at most one rpc per connection at a time makes the routing exact. */ + private readonly rpcQueues = new WeakMap>(); constructor(options: DaemonServerOptions = {}) { this.dir = options.dir ?? getDaemonDir(); @@ -468,6 +477,28 @@ export class DaemonServer { }); } const client = this.registry.clientFor(params.name, params.requireExplicit); + const previous = this.rpcQueues.get(client) ?? Promise.resolve(); + const run = previous.then(() => + this.runRpcOnClient(client, requestId, params, elicitation), + ); + // Keep the queue alive past failures; each caller still sees its own + // error through `run`. + this.rpcQueues.set( + client, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; + } + + private async runRpcOnClient( + client: InspectorClient, + requestId: string, + params: RpcParams, + elicitation: ElicitationChannel, + ): Promise { const methodArgs = stripConnectionFields(params); const unwire = wireElicitationBridge(client, elicitation, requestId); let outcome; @@ -523,7 +554,24 @@ export class DaemonServer { ok: true, result: { streaming: true, label: outcome.label }, }, - startStream: outcome.start, + startStream: (write, end) => { + const stop = outcome.start(write); + // Tie the stream to its connection's lifecycle: when the named + // connection reaches a terminal state (mcpdo disconnect, a + // connections/use replacement, or a transport failure), end the + // stream instead of leaving the caller attached to a stale client + // until Ctrl-C or daemon idle shutdown. + const onStatus = ( + event: TypedEventGeneric, + ) => { + if (isTerminalStatus(event.detail)) end(); + }; + client.addEventListener("statusChange", onStatus); + return () => { + client.removeEventListener("statusChange", onStatus); + stop(); + }; + }, }; } From b7a675b30694c2a123a06d55d797031504f29a4f Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 21:43:43 -0700 Subject: [PATCH 31/69] fix(daemon-cli, cli): address Copilot review round 22 on #1783 - Cancel daemon-side connects when the frontend disconnects: ipc-glue aborts a per-socket AbortSignal on close, threaded through handle() into ConnectionRegistry.connect, which races client.connect() against the abort, tears the in-flight client down, and refuses post-abort registration (no pinned pendingConnects, no unwanted late connection). - Reject explicit resources/unsubscribe while refcounted subscribe streams share the URI's subscription, with guidance to close the streams instead (prevents silent streams + double unsubscribe). - Keep `format` frontend-only: dispatch no longer sends it and the daemon's stripConnectionFields drops it defensively, so JSON tool calls no longer trigger a hidden collectAppInfo resources/read whose result mcpdo discards. Explicit --app-info is unaffected. - Tests: pre-aborted/mid-flight/post-connect cancellation (registry), socket-close signal abort (ipc-glue), format stripping end-to-end (DaemonServer), rpc params omit format (dispatch), unsubscribe rejection + release (run-method). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 31 ++++ clients/cli/src/handlers/run-method.ts | 11 ++ .../__tests__/daemon-connections.test.ts | 153 ++++++++++++++++++ .../__tests__/daemon-ipc-glue.test.ts | 22 +++ clients/daemon-cli/__tests__/dispatch.test.ts | 20 +++ clients/daemon-cli/src/connection/dispatch.ts | 5 +- clients/daemon-cli/src/daemon/connections.ts | 86 ++++++++-- clients/daemon-cli/src/daemon/ipc-glue.ts | 16 +- clients/daemon-cli/src/daemon/server.ts | 15 +- 9 files changed, 339 insertions(+), 20 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index 0b0b89fdfb..a80f7abbea 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -108,6 +108,37 @@ describe("runMethod (mocked client)", () => { expect(failing.unsubscribeFromResource).toHaveBeenCalledTimes(1); }); + it("rejects explicit unsubscribe while subscribe streams share the URI", async () => { + const client = mockClient(); + const sub = await runMethod(client, { + method: "resources/subscribe", + uri: "test://shared", + }); + expect(sub.kind).toBe("stream"); + const stop = sub.kind === "stream" ? sub.start(() => {}) : () => {}; + + // Tearing down the shared subscription out from under the open stream + // (and double-unsubscribing later) is refused with guidance. + await expect( + runMethod(client, { + method: "resources/unsubscribe", + uri: "test://shared", + }), + ).rejects.toThrow(/active resources\/subscribe stream/); + expect(client.unsubscribeFromResource).not.toHaveBeenCalled(); + + // Once the last stream closes, its cleanup unsubscribes and an explicit + // unsubscribe is allowed again. + stop(); + expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1); + const out = await runMethod(client, { + method: "resources/unsubscribe", + uri: "test://shared", + }); + expect(out.kind).toBe("result"); + expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(2); + }); + it("covers subscribe stream, tasks, complete, and app-info call", async () => { const client = mockClient({ callTool: vi.fn().mockResolvedValue({ diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index 0ed5f64df0..8e82fe25b6 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -271,6 +271,17 @@ export async function runMethod( "URI is required for resources/unsubscribe. Use --uri to specify the resource URI.", ); } + // Subscribe streams share one server-side subscription per URI (see + // resourceStreamRefs above). An explicit unsubscribe here would tear + // that shared subscription down while the counted streams stay open + // and silent — and the last stream's cleanup would unsubscribe again. + const activeStreams = + resourceStreamRefs.get(inspectorClient)?.get(args.uri) ?? 0; + if (activeStreams > 0) { + throw new Error( + `Cannot unsubscribe: ${activeStreams} active resources/subscribe stream(s) share this URI's subscription. Close those streams (Ctrl-C) instead; the subscription ends when the last one closes.`, + ); + } await inspectorClient.unsubscribeFromResource(args.uri); result = { unsubscribed: true, uri: args.uri }; } else if (args.method === "prompts/list") { diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 03133e366e..537da80b13 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -401,6 +401,112 @@ describe("ConnectionRegistry", () => { } }); + it("rejects a connect whose caller is already gone (pre-aborted signal)", async () => { + const registry = new ConnectionRegistry(0); + const ac = new AbortController(); + ac.abort(); + const { command, args } = getTestMcpServerCommand(); + await expect( + registry.connect( + { + name: "gone", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + ac.signal, + ), + ).rejects.toThrow(/Connect cancelled/); + expect(registry.connectionCount()).toBe(0); + }); + + it("cancels an in-flight connect when the caller disconnects, tearing the client down", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + let releaseConnect!: () => void; + const gate = new Promise((resolve) => (releaseConnect = resolve)); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockImplementation(() => gate); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const registry = new ConnectionRegistry(0); + try { + const ac = new AbortController(); + const pending = registry.connect( + { + name: "slow", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }, + ac.signal, + ); + // Let the connect get in flight before hanging up. + const deadline = Date.now() + 3000; + while (connectSpy.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(connectSpy).toHaveBeenCalledTimes(1); + ac.abort(); + await expect(pending).rejects.toThrow(/Connect cancelled/); + // The abandoned client was torn down, not left dialing. + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(registry.connectionCount()).toBe(0); + // The late settlement of the abandoned connect is observed by the + // cancellation race, so it never surfaces as an unhandled rejection. + releaseConnect(); + await new Promise((resolve) => setTimeout(resolve, 5)); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + } + }); + + it("discards a connect that completes only after the caller hung up", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const ac = new AbortController(); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValue(undefined); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + // Abort between connect settling and registration (the auth snapshot + // read sits exactly there), hitting the post-connect abort check. + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockImplementation(async () => { + ac.abort(); + return undefined as never; + }); + const registry = new ConnectionRegistry(0); + try { + await expect( + registry.connect( + { + name: "late", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }, + ac.signal, + ), + ).rejects.toThrow(/Connect cancelled/); + // Connected fine — but registering would leak a connection nobody + // asked to keep, so it was disconnected instead. + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(registry.connectionCount()).toBe(0); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const { NodeOAuthStorage, resetNodeOAuthStorageCache } = @@ -793,4 +899,51 @@ describe("DaemonServer IPC", () => { spy.mockRestore(); } }); + + it("strips format from rpc method args so JSON tool calls skip the app-info probe", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-fmt-")); + server = new DaemonServer({ dir, idleMs: 0 }); + const { command, args } = getTestMcpServerCommand(); + await server.registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }); + + const res = await server.handle({ + id: "1", + op: "rpc", + params: { + method: "tools/call", + name: "s", + toolName: "echo", + toolArg: { message: "hi" }, + format: "json", + }, + }); + expect(res.ok).toBe(true); + const rpc = (res as { result: { kind: string; appInfo?: unknown } }).result; + expect(rpc.kind).toBe("result"); + // format is a frontend-only output concern: forwarding it used to make + // runMethod collect app info (a hidden extra resources/read) whose + // result the frontend discards. + expect(rpc.appInfo).toBeUndefined(); + + // Explicit --app-info still probes. + const withApp = await server.handle({ + id: "2", + op: "rpc", + params: { + method: "tools/call", + name: "s", + toolName: "echo", + appInfo: true, + }, + }); + expect(withApp.ok).toBe(true); + const appRes = ( + withApp as { result: { result: { hasApp?: boolean; toolName?: string } } } + ).result; + expect(appRes.result).toMatchObject({ hasApp: false, toolName: "echo" }); + }); }); diff --git a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts index 96d3231eb5..3bc30bc45b 100644 --- a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts +++ b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts @@ -41,6 +41,7 @@ function accept( handle: ( request: DaemonRequest, elicitation: ElicitationChannel, + signal?: AbortSignal, ) => Promise<{ response: { id: string; ok: true; result: unknown }; startStream?: ( @@ -181,6 +182,27 @@ describe("acceptDaemonConnection elicitation channel", () => { }); describe("acceptDaemonConnection guards", () => { + it("aborts the per-request signal when the caller's socket closes", async () => { + let seen: AbortSignal | undefined; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const socket = accept(async (request, _elicitation, signal) => { + seen = signal; + await gate; + return { response: { id: request.id, ok: true, result: {} } }; + }); + + socket.pushLine(REQUEST); + await until(() => seen !== undefined); + // Caller still attached: nothing aborted. + expect(seen!.aborted).toBe(false); + socket.destroy(); + await until(() => seen!.aborted === true); + release(); + }); + it("drops the response when the socket dies mid-handle", async () => { let release: () => void = () => {}; const gate = new Promise((resolve) => { diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index e82bf16479..9dedc904a4 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -84,6 +84,26 @@ describe("dispatchConnectionRpc", () => { expect(stdout).toContain("\n"); }); + it("omits format from the daemon rpc params (frontend-only concern)", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "tools/call", + { toolName: "echo" }, + { format: "json", requireExplicit: false }, + ); + const [op, params] = callDaemon.mock.calls[0] as [ + string, + Record, + ]; + expect(op).toBe("rpc"); + // Forwarding format would make the daemon's runMethod issue a hidden + // app-info resources/read for JSON tool calls. + expect("format" in params).toBe(false); + expect(params).toMatchObject({ method: "tools/call", toolName: "echo" }); + }); + it("writes human text for tools/list by default", async () => { callDaemon.mockResolvedValue({ kind: "result", diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index f029255b30..d0a2cedfa0 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -38,7 +38,10 @@ export async function dispatchConnectionRpc( const style = styleFromOpts({ plain: opts.plain, format }); const params: RpcParams = { ...methodArgs, - format, + // `format` stays frontend-only: forwarding it would make the daemon's + // runMethod treat `format: "json"` tool calls as app-info requests and + // issue a hidden extra resources/read whose result we discard. The + // daemon also strips it defensively (see stripConnectionFields). method, name: stripAt(opts.connection), requireExplicit: opts.requireExplicit, diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 5131adc036..0d83568344 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -237,26 +237,38 @@ export class ConnectionRegistry { }; } - async connect(params: { - name: string; - serverConfig: MCPServerConfig; - serverSettings?: InspectorServerSettings; - serverIdentity: string; - }): Promise { - return this.withNameLock(params.name, () => this.connectLocked(params)); + async connect( + params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; + }, + signal?: AbortSignal, + ): Promise { + return this.withNameLock(params.name, () => + this.connectLocked(params, signal), + ); } - private async connectLocked(params: { - name: string; - serverConfig: MCPServerConfig; - serverSettings?: InspectorServerSettings; - serverIdentity: string; - }): Promise { + private async connectLocked( + params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; + }, + signal?: AbortSignal, + ): Promise { this.assertOpen(); this.clearIdleTimer(); this.pendingConnects++; try { + // Caller may already be gone (e.g. Ctrl-C while queued on the name + // lock); don't start dialing on behalf of nobody. + if (signal?.aborted) throw connectCancelledError(); + if (this.connections.has(params.name)) { // Reconnect: tear down the previous client first. await this.disconnectLocked(params.name); @@ -272,7 +284,32 @@ export class ConnectionRegistry { ); try { - await client.connect(); + // Race the connect against caller hang-up: when the requesting + // socket closes mid-dial (Ctrl-C, frontend crash) the daemon must + // not keep the attempt alive — with `--connect-timeout 0` it would + // otherwise pin `pendingConnects` (blocking idle shutdown) or + // register a connection the user cancelled. On abort the shared + // catch below tears the client down, which also cancels the + // still-in-flight connect; its eventual settlement is observed by + // the race's handlers, so nothing rejects unhandled. + if (!signal) { + await client.connect(); + } else { + await new Promise((resolve, reject) => { + const onAbort = () => reject(connectCancelledError()); + signal.addEventListener("abort", onAbort, { once: true }); + client.connect().then( + () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); + } } catch (error) { await safeDisconnect(client); if (isConnectionAuthRequiredError(error)) { @@ -295,6 +332,13 @@ export class ConnectionRegistry { await safeDisconnect(client); this.assertOpen(); } + if (signal?.aborted) { + // Caller hung up after the connect completed but before + // registration; keeping the client would leak a live connection + // nobody asked to retain. + await safeDisconnect(client); + throw connectCancelledError(); + } this.connections.set(params.name, { name: params.name, serverIdentity: params.serverIdentity, @@ -599,3 +643,17 @@ async function safeDisconnect(client: InspectorClient): Promise { // Best-effort teardown. } } + +/** + * Error thrown when a connect is abandoned because the requesting client's + * socket closed. The response is written to a dead socket, so the exit code + * only matters for in-process callers; UNREACHABLE ("no connection was + * established") is the closest fit. + */ +function connectCancelledError(): CliExitCodeError { + return new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Connect cancelled: the requesting client disconnected while the connection was still in progress.", + { code: "connect_cancelled" }, + ); +} diff --git a/clients/daemon-cli/src/daemon/ipc-glue.ts b/clients/daemon-cli/src/daemon/ipc-glue.ts index ad33f9df1f..9b78745281 100644 --- a/clients/daemon-cli/src/daemon/ipc-glue.ts +++ b/clients/daemon-cli/src/daemon/ipc-glue.ts @@ -44,6 +44,11 @@ export type ElicitationChannel = { export type HandleRequest = ( request: DaemonRequest, elicitation: ElicitationChannel, + /** + * Aborted when the requesting socket closes, so long-running handlers + * (notably `connect`) can stop work whose caller is gone. + */ + signal?: AbortSignal, ) => Promise; /** @@ -160,6 +165,11 @@ export function acceptDaemonConnection( // socket's own error handler below owns the teardown. rl.on("error", () => {}); const elicitationChannel = new ConnectionElicitationChannel(socket); + // Cancellation for in-flight handlers: when the caller hangs up (Ctrl-C + // closes its socket) the handler should stop working on its behalf — + // e.g. abort a `connect` that would otherwise keep dialing indefinitely. + const requestAbort = new AbortController(); + socket.once("close", () => requestAbort.abort()); rl.on("line", (line) => { void (async () => { // readline sees the same chunks as the cap enforcement above, so an @@ -185,7 +195,11 @@ export function acceptDaemonConnection( ); return; } - const outcome = await handle(request, elicitationChannel); + const outcome = await handle( + request, + elicitationChannel, + requestAbort.signal, + ); if (socket.destroyed) { // The caller vanished while the handler ran. A stream outcome may // already hold producer-side state (resources/subscribe subscribes diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index b622661aed..4529988044 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -268,20 +268,22 @@ export class DaemonServer { async handle( request: DaemonRequest, elicitation: ElicitationChannel = autoCancelElicitationChannel, + signal?: AbortSignal, ): Promise { - return (await this.handleOutcome(request, elicitation)).response; + return (await this.handleOutcome(request, elicitation, signal)).response; } /** Full handle including optional stream starter (socket accept path). */ async handleOutcome( request: DaemonRequest, elicitation: ElicitationChannel = autoCancelElicitationChannel, + signal?: AbortSignal, ): Promise { try { assertDaemonToken(this.requiredToken, request.token); this.activeOps++; try { - return await this.dispatch(request, elicitation); + return await this.dispatch(request, elicitation, signal); } finally { this.activeOps--; if (this.activeOps === 0) { @@ -321,6 +323,7 @@ export class DaemonServer { private async dispatch( request: DaemonRequest, elicitation: ElicitationChannel, + signal?: AbortSignal, ): Promise { // Once shutdown starts, new work is rejected: an op accepted here could // otherwise register a live client after disconnectAll's snapshot. @@ -360,7 +363,7 @@ export class DaemonServer { response: { id: request.id, ok: true, - result: await this.registry.connect(params), + result: await this.registry.connect(params, signal), }, }; } @@ -696,8 +699,12 @@ function isPidAlive(pid: number): boolean { function stripConnectionFields( params: RpcParams, ): MethodArgs & { method: string } { - const { name, requireExplicit, method, ...rest } = params; + // `format` is a frontend-only output concern; forwarding it would make + // runMethod's `format === "json"` branch collect app info (an extra + // resources/read) whose result the frontend discards. + const { name, requireExplicit, format, method, ...rest } = params; void name; void requireExplicit; + void format; return { method, ...rest }; } From 4012412a09a822f0710f5b8f979afc840a35d261 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 22:14:49 -0700 Subject: [PATCH 32/69] docs(daemon-cli): address Copilot review round 23 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct the misleading getDaemonTokenFromEnv doc comment: an unset environment variable selects shared mode, which is still authenticated — the daemon generates its own required token and publishes it to daemon.token. It never creates an unauthenticated daemon. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/daemon-cli/src/daemon/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/clients/daemon-cli/src/daemon/auth.ts b/clients/daemon-cli/src/daemon/auth.ts index 12f38a8876..37c9f34152 100644 --- a/clients/daemon-cli/src/daemon/auth.ts +++ b/clients/daemon-cli/src/daemon/auth.ts @@ -24,7 +24,10 @@ export function readDaemonTokenFile(dir?: string): string | undefined { /** * Read the IPC token from the environment (parent client or daemon child). - * Empty / unset → shared (unauthenticated) mode. + * Empty / unset → shared mode, which is still authenticated: the daemon + * generates its own required token (see `daemon/run.ts`) and publishes it + * to `daemon.token` for same-user clients to read. Every daemon requires a + * token; the environment variable only selects who supplies it. */ export function getDaemonTokenFromEnv( env: NodeJS.ProcessEnv = process.env, From be7d78ef6cf6199236e627f47e5797d353c20408 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 22:50:00 -0700 Subject: [PATCH 33/69] fix(daemon-cli, cli): address Copilot review round 24 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class-level fixes rather than point patches: - Abort-listener races: new withAbort helper in connections.ts takes a thunk, checks aborted synchronously, and installs the listener before starting the operation — no check/listen gap can hang a stalled connect. Applied the same pre-aborted check to callDaemon and streamDaemon, which had the identical class of bug (SIGINT during ensureDaemon would never fire their listeners; with timeoutMs 0 the request hung forever). - IPC seam: the acceptDaemonConnection adapter in DaemonServer.start now forwards the per-socket abort signal into handleOutcome (it was silently dropped, making round-22's cancellation inert over real sockets). Added a true end-to-end socket test — client aborts a gated connect, daemon must tear down the dial — so this seam cannot silently regress again. - Check-then-act on shared subscription state: resourceStreamRefs entries are now { count, ready } reserved synchronously before any await; concurrent same-URI subscribes join one in-flight subscribe promise, failures roll back their own reservation (last one out deletes the entry for clean retry), and stream stops are idempotent with stale-generation delete guards. - Tests: e2e socket cancellation, reconnect-window abort (pre-start check), pre-aborted callDaemon/streamDaemon, concurrent subscribe sharing + double-stop, subscribe failure rollback + retry. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 58 ++++++++++ clients/cli/src/handlers/run-method.ts | 60 ++++++++--- .../__tests__/daemon-connections.test.ts | 101 ++++++++++++++++++ .../__tests__/daemon-coverage.test.ts | 17 +++ .../__tests__/daemon-stream.test.ts | 18 ++++ clients/daemon-cli/src/daemon/client.ts | 9 +- clients/daemon-cli/src/daemon/connections.ts | 55 ++++++---- clients/daemon-cli/src/daemon/server.ts | 4 +- .../daemon-cli/src/daemon/stream-client.ts | 8 +- 9 files changed, 292 insertions(+), 38 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index a80f7abbea..cd982c316b 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -108,6 +108,64 @@ describe("runMethod (mocked client)", () => { expect(failing.unsubscribeFromResource).toHaveBeenCalledTimes(1); }); + it("concurrent same-URI subscribes share one in-flight subscription", async () => { + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const client = mockClient({ + subscribeToResource: vi.fn().mockImplementation(() => gate), + } as Partial); + // Both setups race before the subscribe resolves; the reservation is + // synchronous, so they must join one in-flight subscribe rather than + // each subscribing and writing a count of 1. + const p1 = runMethod(client, { + method: "resources/subscribe", + uri: "test://race", + }); + const p2 = runMethod(client, { + method: "resources/subscribe", + uri: "test://race", + }); + release(); + const [s1, s2] = await Promise.all([p1, p2]); + expect(client.subscribeToResource).toHaveBeenCalledTimes(1); + const stop1 = s1.kind === "stream" ? s1.start(() => {}) : () => {}; + const stop2 = s2.kind === "stream" ? s2.start(() => {}) : () => {}; + stop1(); + stop1(); // double-stop must not corrupt the shared count + expect(client.unsubscribeFromResource).not.toHaveBeenCalled(); + stop2(); + expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1); + }); + + it("rolls back reservations when the shared subscribe fails, allowing retry", async () => { + const subscribe = vi + .fn() + .mockRejectedValueOnce(new Error("subscribe boom")) + .mockResolvedValue(undefined); + const client = mockClient({ + subscribeToResource: subscribe, + } as Partial); + const p1 = runMethod(client, { + method: "resources/subscribe", + uri: "test://fail", + }); + const p2 = runMethod(client, { + method: "resources/subscribe", + uri: "test://fail", + }); + await expect(p1).rejects.toThrow("subscribe boom"); + await expect(p2).rejects.toThrow("subscribe boom"); + // Both joined the same failed attempt… + expect(subscribe).toHaveBeenCalledTimes(1); + // …and both rolled back, so a retry issues a fresh subscribe. + const s3 = await runMethod(client, { + method: "resources/subscribe", + uri: "test://fail", + }); + expect(subscribe).toHaveBeenCalledTimes(2); + expect(s3.kind).toBe("stream"); + }); + it("rejects explicit unsubscribe while subscribe streams share the URI", async () => { const client = mockClient(); const sub = await runMethod(client, { diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index 8e82fe25b6..f2c71b491f 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -73,7 +73,24 @@ function assertSkillsSupported( * so the unsubscribe must be reference-counted: tearing it down when the * first stream closes would leave the survivors open but silent. */ -const resourceStreamRefs = new WeakMap>(); +const resourceStreamRefs = new WeakMap< + InspectorClient, + Map +>(); + +/** + * One server-side subscription shared by every open subscribe stream for a + * given client + URI. The entry is the synchronization point for concurrent + * setups: it is reserved synchronously (before any await), so racing streams + * all join the same in-flight `ready` promise instead of each subscribing + * and corrupting the count. Consumers are counted from reservation; on + * subscribe failure each waiter rolls back its own reservation and the last + * one out removes the entry so a later subscribe can retry cleanly. + */ +type SharedResourceSubscription = { + count: number; + ready: Promise; +}; /** * Run one MCP method against a connected {@link InspectorClient}. @@ -225,13 +242,24 @@ export async function runMethod( resourceStreamRefs.set(inspectorClient, refs); } const uri = args.uri; - const priorConsumers = refs.get(uri) ?? 0; - // Only the first consumer subscribes; the count is bumped after the - // subscribe succeeds so a failure leaves nothing to unwind. - if (priorConsumers === 0) { - await inspectorClient.subscribeToResource(uri); + // Reserve before awaiting (see SharedResourceSubscription): the first + // arrival creates the entry with the in-flight subscribe, and every + // concurrent arrival joins it. The stream is only exposed once the + // shared subscribe has succeeded. + let shared = refs.get(uri); + if (!shared) { + shared = { count: 0, ready: inspectorClient.subscribeToResource(uri) }; + refs.set(uri, shared); + } + const entry = shared; + entry.count++; + try { + await entry.ready; + } catch (error) { + entry.count--; + if (entry.count === 0 && refs.get(uri) === entry) refs.delete(uri); + throw error; } - refs.set(uri, priorConsumers + 1); return { kind: "stream", label: "resources/subscribe", @@ -249,14 +277,18 @@ export async function runMethod( }); }; inspectorClient.addEventListener("resourceUpdated", onUpdate); + let closed = false; return () => { + // A second stop from any caller must not double-decrement the + // shared count. + if (closed) return; + closed = true; inspectorClient.removeEventListener("resourceUpdated", onUpdate); - const remaining = (refs.get(uri) ?? 1) - 1; - if (remaining > 0) { - refs.set(uri, remaining); - return; - } - refs.delete(uri); + entry.count--; + if (entry.count > 0) return; + // Guard against deleting a successor generation: only remove + // the mapping if it is still this stream's entry. + if (refs.get(uri) === entry) refs.delete(uri); // Catch the rejection here: this stop can run during daemon // shutdown after disconnectAll has closed the client, where the // unsubscribe rejects; a bare `void` would surface that as an @@ -276,7 +308,7 @@ export async function runMethod( // that shared subscription down while the counted streams stay open // and silent — and the last stream's cleanup would unsubscribe again. const activeStreams = - resourceStreamRefs.get(inspectorClient)?.get(args.uri) ?? 0; + resourceStreamRefs.get(inspectorClient)?.get(args.uri)?.count ?? 0; if (activeStreams > 0) { throw new Error( `Cannot unsubscribe: ${activeStreams} active resources/subscribe stream(s) share this URI's subscription. Close those streams (Ctrl-C) instead; the subscription ends when the last one closes.`, diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 537da80b13..0bdbed45c6 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -464,6 +464,51 @@ describe("ConnectionRegistry", () => { } }); + it("does not start dialing when the abort lands during reconnect teardown", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const ac = new AbortController(); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValue(undefined); + // Abort while the reconnect path is tearing down the previous client — + // after the entry abort check, before the dial. AbortSignal does not + // replay, so only withAbort's synchronous pre-start check catches this. + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockImplementation(async () => { + ac.abort(); + }); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + const params = { + name: "re", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + } as const; + try { + await registry.connect(params); + expect(connectSpy).toHaveBeenCalledTimes(1); + await expect(registry.connect(params, ac.signal)).rejects.toThrow( + /Connect cancelled/, + ); + // The second dial never started: the signal was checked synchronously + // before invoking connect, with no listener-install gap to hang in. + expect(connectSpy).toHaveBeenCalledTimes(1); + // Reconnect teardown plus the cancelled attempt's cleanup. + expect(disconnectSpy).toHaveBeenCalledTimes(2); + expect(registry.connectionCount()).toBe(0); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("discards a connect that completes only after the caller hung up", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); const ac = new AbortController(); @@ -900,6 +945,62 @@ describe("DaemonServer IPC", () => { } }); + it("cancels a daemon-side connect end-to-end when the caller's socket closes", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-cancel-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + let releaseConnect!: () => void; + const gate = new Promise((resolve) => (releaseConnect = resolve)); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockImplementation(() => gate); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + try { + // Full real-socket path: this is the seam test that unit tests on + // either side of the IPC adapter cannot cover (a dropped signal + // argument in the adapter would pass both and fail here). + const ac = new AbortController(); + const pending = callDaemon( + "connect", + { + name: "hung", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }, + { socketPath: server.socketPath, timeoutMs: 0, signal: ac.signal }, + ); + let deadline = Date.now() + 3000; + while (connectSpy.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(connectSpy).toHaveBeenCalledTimes(1); + // Frontend hangs up (Ctrl-C): its socket is destroyed… + ac.abort(); + await expect(pending).rejects.toThrow(/cancelled/); + // …and the daemon-side dial is torn down without ever completing. + deadline = Date.now() + 3000; + while (disconnectSpy.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(server.registry.connectionCount()).toBe(0); + // A late success is discarded, never registered. + releaseConnect(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(server.registry.connectionCount()).toBe(0); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + } + }); + it("strips format from rpc method args so JSON tool calls skip the app-info probe", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-fmt-")); server = new DaemonServer({ dir, idleMs: 0 }); diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 710e6c87c7..68d075074c 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -278,6 +278,23 @@ describe("daemon coverage", () => { } }); + it("callDaemon rejects immediately on a pre-aborted signal instead of hanging", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const ac = new AbortController(); + ac.abort(); + // timeoutMs 0 = no timer: without the pre-aborted check (AbortSignal + // does not replay) this request would hang forever. + await expect( + callDaemon( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 0, signal: ac.signal }, + ), + ).rejects.toThrow(/cancelled/); + }); + it("callDaemon maps error responses and unreachable sockets", async () => { await expect( callDaemon( diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index 5d5fa34411..d0c6a8a5d3 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -254,6 +254,24 @@ describe("streamDaemon + ipc-glue", () => { ).rejects.toThrow(); }); + it("finishes immediately on a pre-aborted signal instead of hanging", async () => { + const sock = freshSock(); + await listen(sock, () => { + // Never respond: only the pre-aborted check can settle this promptly. + }); + const ac = new AbortController(); + ac.abort(); + await streamDaemon( + {}, + { + socketPath: sock, + timeoutMs: 5000, + signal: ac.signal, + onData: () => {}, + }, + ); + }); + it("aborts via signal after the stream opens", async () => { const sock = freshSock(); await listen(sock, (socket) => { diff --git a/clients/daemon-cli/src/daemon/client.ts b/clients/daemon-cli/src/daemon/client.ts index d98d2db402..c0e126e28d 100644 --- a/clients/daemon-cli/src/daemon/client.ts +++ b/clients/daemon-cli/src/daemon/client.ts @@ -243,7 +243,14 @@ export async function callDaemon( }, timeoutMs) : undefined; - options.signal?.addEventListener("abort", onAbort, { once: true }); + // AbortSignal does not replay: a signal that aborted before this point + // (e.g. SIGINT during ensureDaemon) would never fire the listener, and + // with timeoutMs 0 the request would hang forever. Check first. + if (options.signal?.aborted) { + onAbort(); + } else { + options.signal?.addEventListener("abort", onAbort, { once: true }); + } socket.once("connect", () => { socket.write(encodeRequest(request)); diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 0d83568344..2ae8df8727 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -290,26 +290,8 @@ export class ConnectionRegistry { // otherwise pin `pendingConnects` (blocking idle shutdown) or // register a connection the user cancelled. On abort the shared // catch below tears the client down, which also cancels the - // still-in-flight connect; its eventual settlement is observed by - // the race's handlers, so nothing rejects unhandled. - if (!signal) { - await client.connect(); - } else { - await new Promise((resolve, reject) => { - const onAbort = () => reject(connectCancelledError()); - signal.addEventListener("abort", onAbort, { once: true }); - client.connect().then( - () => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); - } + // still-in-flight connect. + await withAbort(() => client.connect(), signal, connectCancelledError); } catch (error) { await safeDisconnect(client); if (isConnectionAuthRequiredError(error)) { @@ -644,6 +626,39 @@ async function safeDisconnect(client: InspectorClient): Promise { } } +/** + * Run a cancellable async operation. Guards the whole class of + * abort-listener races: `AbortSignal` does not replay its event, so any + * "check aborted, await something, then addEventListener" sequence can miss + * an abort that fired in the gap and hang forever. Here the aborted check is + * synchronous and the listener is installed *before* the operation starts, + * so no abort can interleave. When aborted pre-start the operation is never + * invoked; when aborted mid-flight its eventual settlement is still + * observed by the race handlers, so nothing rejects unhandled. + */ +async function withAbort( + start: () => Promise, + signal: AbortSignal | undefined, + makeError: () => Error, +): Promise { + if (!signal) return start(); + if (signal.aborted) throw makeError(); + return await new Promise((resolve, reject) => { + const onAbort = () => reject(makeError()); + signal.addEventListener("abort", onAbort, { once: true }); + start().then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + /** * Error thrown when a connect is abandoned because the requesting client's * socket closed. The response is written to a dead socket, so the exit code diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 4529988044..f1ae36556b 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -156,8 +156,8 @@ export class DaemonServer { this.server = net.createServer((socket) => { this.ipcSockets.add(socket); socket.once("close", () => this.ipcSockets.delete(socket)); - acceptDaemonConnection(socket, (req, elicitation) => - this.handleOutcome(req, elicitation), + acceptDaemonConnection(socket, (req, elicitation, signal) => + this.handleOutcome(req, elicitation, signal), ); }); diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index cd909ad6a2..5db09211c7 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -204,7 +204,13 @@ export async function streamDaemon( ); }, timeoutMs); - options.signal?.addEventListener("abort", onAbort, { once: true }); + // AbortSignal does not replay: a pre-aborted signal would never fire + // the listener, leaving the stream open until the timeout. Check first. + if (options.signal?.aborted) { + onAbort(); + } else { + options.signal?.addEventListener("abort", onAbort, { once: true }); + } socket.once("connect", () => { socket.write(encodeRequest(request)); From a88e4bdee0c80653c1793d26cf2a8a4c26d009eb Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 23:18:47 -0700 Subject: [PATCH 34/69] fix(daemon-cli): address Copilot review round 25 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispatch: stream path now passes timeoutMs 0 to streamDaemon, matching the rpc path — core enforces the configured MCP request timeout daemon-side, so a fixed local 60s deadline falsely failed valid long-running stream setups. - stream-client: honor timeoutMs 0 as "no deadline" (mirrors callDaemon); the previous unconditional setTimeout would have fired a 0ms timer immediately instead of disabling it. - form-prompt: blank input on a required multiselect now submits [] when (minItems ?? 0) === 0 — JSON Schema "required" only demands presence, and the prompt previously looped forever with no way to select none; minItems >= 1 still re-prompts with the minimum. - tests: streamDaemon timeoutMs-0 deadline-disable regression, dispatch stream timeoutMs assertion, required-multiselect blank submit and minItems re-prompt cases. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-stream.test.ts | 21 ++++++++++ clients/daemon-cli/__tests__/dispatch.test.ts | 6 +++ .../daemon-cli/__tests__/form-prompt.test.ts | 40 +++++++++++++++++++ clients/daemon-cli/src/connection/dispatch.ts | 4 ++ .../daemon-cli/src/connection/form-prompt.ts | 9 +++++ .../daemon-cli/src/daemon/stream-client.ts | 24 ++++++----- 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index d0c6a8a5d3..872ed5881b 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -310,6 +310,27 @@ describe("streamDaemon + ipc-glue", () => { ).rejects.toThrow(/timed out/); }, 5000); + it("disables the open deadline entirely with timeoutMs 0", async () => { + // Regression: an unconditional setTimeout(..., 0) fired on the next + // tick, so timeoutMs 0 (documented as "no deadline") failed every + // stream immediately instead of waiting indefinitely. + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + setTimeout(() => { + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n"); + }, 120); + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 0, onData: () => {} }), + ).resolves.toBeUndefined(); + }, 5000); + it("fails when the peer FINs before the stream ok frame", async () => { const sock = freshSock(); await listen(sock, (socket) => { diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index 9dedc904a4..17877b141b 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -157,6 +157,12 @@ describe("dispatchConnectionRpc", () => { ); expect(stdout).toContain("Subscribed:"); expect(streamDaemon).toHaveBeenCalled(); + // Core enforces the configured MCP request timeout daemon-side; the + // stream path must disable the fixed local deadline like the rpc path. + expect(streamDaemon).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeoutMs: 0 }), + ); }); it("flushes queued stream writes before returning", async () => { diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index 3148257a95..0b6e773016 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -373,6 +373,46 @@ describe("promptForm", () => { expect(stderr).toContain("Select at least 2"); }); + it("accepts blank on a required multi-select as an empty array when minItems permits", async () => { + // JSON Schema `required` means the key must be present; [] is a valid + // value unless minItems forbids it. Previously this looped forever. + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + ], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { colors: [] } }); + expect(stderr).not.toContain("This field is required"); + }); + + it("re-prompts blank on a required multi-select when minItems demands entries", async () => { + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + ], + minItems: 1, + }; + const rl = fakeRl(["", "1", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { colors: ["red"] }, + }); + expect(stderr).toContain("Select at least 1"); + }); + it("uses a multi-select default on blank, formatted in the field description", async () => { const field: FormField = { name: "colors", diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index d0a2cedfa0..9544b59437 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -61,6 +61,10 @@ export async function dispatchConnectionRpc( try { await streamDaemon(params, { socketPath, + // Core enforces the configured MCP request timeout daemon-side; a + // fixed local deadline would falsely fail stream setups (e.g. a + // subscribe against a slow server) that are still valid. + timeoutMs: 0, signal: ac.signal, onData: (data) => { writeChain = writeChain diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index c2472e831d..9a49304eda 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -102,6 +102,15 @@ async function promptField( if (raw === "") { if (field.default !== undefined) return field.default; if (!field.required) return undefined; + if (multi) { + const m = field as Extract; + // JSON Schema "required" only means the key must be present; an + // empty array is a valid value unless minItems forbids it. Without + // this, "none selected" on a required multiselect loops forever. + if ((m.minItems ?? 0) === 0) return []; + process.stderr.write(style.red(` Select at least ${m.minItems}.\n`)); + continue; + } process.stderr.write(style.red(" This field is required.\n")); continue; } diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index 5db09211c7..f692003c89 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -194,15 +194,21 @@ export async function streamDaemon( ); }); - timer = setTimeout(() => { - fail( - new CliExitCodeError( - EXIT_CODES.UNREACHABLE, - `Daemon stream open timed out after ${timeoutMs}ms`, - { code: "daemon_timeout" }, - ), - ); - }, timeoutMs); + // timeoutMs 0 disables the deadline (mirrors callDaemon): an + // unconditional setTimeout(..., 0) would fire immediately, failing + // every stream on the next tick instead of never. + timer = + timeoutMs > 0 + ? setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon stream open timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs) + : undefined; // AbortSignal does not replay: a pre-aborted signal would never fire // the listener, leaving the stream open until the timeout. Check first. From 10db834b274d4a41ce17f0bbf4d58a8dda98950e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 25 Sep 2026 23:35:32 -0700 Subject: [PATCH 35/69] fix(daemon-cli): address Copilot review round 26 on #1783 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: close the stream disconnect event-registration race. The statusChange listener was only installed inside startStream, which ipc-glue invokes after the ok frame; a disconnect completing between runMethod() and that install lost the terminal event, leaving the stream open against a dead client until Ctrl-C or idle shutdown. startStream now installs the listener first and then checks the current status synchronously — terminal status is persistent state, so the check closes every window back to when the stream went live. - test: open a stream, disconnect in the runMethod->startStream window, assert startStream ends the stream immediately. Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/daemon-connections.test.ts | 42 +++++++++++++++++++ clients/daemon-cli/src/daemon/server.ts | 8 +++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 0bdbed45c6..61bc43e3f6 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -888,6 +888,48 @@ describe("DaemonServer IPC", () => { stop(); }); + it("ends a stream whose connection disconnected before startStream ran", async () => { + // Regression: the statusChange listener was only installed inside + // startStream, which ipc-glue invokes after the ok frame. A disconnect + // completing in the window after runMethod() returned but before the + // listener existed lost the terminal event, leaving the stream open + // against a dead client forever. Terminal status is persistent state, + // so startStream now checks the current status after installing the + // listener. + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-streamrace-")); + server = new DaemonServer({ dir, idleMs: 0 }); + const { command, args } = getTestMcpServerCommand(); + await server.registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }); + + const outcome = await server.handleOutcome({ + id: "st", + op: "stream", + params: { method: "logging/tail", name: "s" } as never, + }); + expect(outcome.response.ok).toBe(true); + + // Disconnect in the window between runMethod() and startStream. + await server.handle({ + id: "d", + op: "disconnect", + params: { name: "s" } as never, + }); + + let ended = 0; + const stop = outcome.startStream!( + () => {}, + () => { + ended += 1; + }, + ); + expect(ended).toBe(1); + stop(); + }); + it("serializes rpc ops per connection so elicitation routing is exact", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpcqueue-")); server = new DaemonServer({ dir, idleMs: 0 }); diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index f1ae36556b..641f124d80 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -558,7 +558,6 @@ export class DaemonServer { result: { streaming: true, label: outcome.label }, }, startStream: (write, end) => { - const stop = outcome.start(write); // Tie the stream to its connection's lifecycle: when the named // connection reaches a terminal state (mcpdo disconnect, a // connections/use replacement, or a transport failure), end the @@ -570,6 +569,13 @@ export class DaemonServer { if (isTerminalStatus(event.detail)) end(); }; client.addEventListener("statusChange", onStatus); + const stop = outcome.start(write); + // Terminal status is persistent state, not just an event: a + // disconnect completing between runMethod() and the listener + // install above would never fire statusChange again, leaving the + // stream open against a dead client. Checking the current status + // after installing the listener closes both sides of that race. + if (isTerminalStatus(client.getStatus())) end(); return () => { client.removeEventListener("statusChange", onStatus); stop(); From 52bdede8890d8eccc1c72a01cb99f13d0fb2876f Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 26 Sep 2026 22:42:01 -0700 Subject: [PATCH 36/69] fix(daemon-cli): adapt to secret-store token storage after v2/main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt mcpdo to #2482 (OAuth tokens split into the secret store) and to its transitive when-exit dependency: - stored-auth: read the shared OAuth store via readOAuthStore instead of parsing oauth.json directly — the raw blob no longer carries tokens, so a file parse would report every auth/list entry as token-less. Joined reads also pick up read-side plaintext migration. - vitest config: pin MCP_INSPECTOR_SECRET_STORE=memory so stored-auth tests (and spawned daemons) never touch the real OS keychain. - stored-auth tests: drop non-object fixture entries (core's proto-safe parser now treats a file containing them as unrecognized rather than empty — it indexes keychain secrets, so it refuses instead of clobbering); purge fixture secrets from the process-wide memory store between tests; add a regression test that tokens split into the secret store by the write path still surface in auth/list. - dispatch tests: invoke only the SIGINT/SIGTERM listener dispatch registers instead of process.emit broadcasts — atomically (via the new secret-store persistence) loads when-exit, whose module-level signal handler kills the vitest worker on a broadcast signal (#1941). Signed-off-by: Bob Dickinson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/connection-stored-auth.test.ts | 89 +++++++++++++++---- clients/daemon-cli/__tests__/dispatch.test.ts | 21 ++++- .../daemon-cli/src/connection/stored-auth.ts | 17 ++-- clients/daemon-cli/vitest.config.ts | 5 ++ package-lock.json | 33 +------ 5 files changed, 106 insertions(+), 59 deletions(-) diff --git a/clients/daemon-cli/__tests__/connection-stored-auth.test.ts b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts index 1079e578a2..4855552f86 100644 --- a/clients/daemon-cli/__tests__/connection-stored-auth.test.ts +++ b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { writeOAuthSections } from "@inspector/core/auth/node/oauth-persist-file.js"; import { clearAllStoredAuth, clearStoredAuth, @@ -11,6 +12,8 @@ import { resolveStoredAuthKey, } from "../src/connection/stored-auth.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { defaultSecretStore } from "@inspector/core/auth/node/secret-store-selection.js"; +import { oauthSecretServerId } from "@inspector/core/auth/node/oauth-secrets.js"; import { runMcp } from "./helpers/mcp-runner.js"; import { expectCliSuccess, @@ -41,8 +44,10 @@ function writeOAuthFixture(dir: string): string { "https://empty.example/mcp": { codeVerifier: "cv", }, - "https://nullish.example/mcp": null, - "https://stringish.example/mcp": "not-an-object", + // Note: entries that are not objects (null, strings) now make the + // whole file unrecognized upstream (proto-safe parsing) — the file + // indexes secret-store entries, so core refuses rather than treats + // it as empty. Junk entries therefore no longer belong in a fixture. "https://issuer-empty.example/mcp": { byIssuer: { "https://as.example/": {}, @@ -72,15 +77,37 @@ function writeOAuthFixture(dir: string): string { return file; } +const FIXTURE_URLS = [ + "https://example.com/mcp", + "https://other.example/mcp", + "https://empty.example/mcp", + "https://issuer-empty.example/mcp", + "https://multi.example/mcp", +]; + +/** + * The pinned in-memory secret store (vitest.config.ts) is process-wide and + * keyed by server URL, not by state-file path — reads migrate fixture + * plaintext into it and joined reads prefer it over the file, so one test's + * migrated tokens would leak into the next test's fresh fixture. + */ +async function purgeFixtureSecrets(): Promise { + const store = defaultSecretStore(); + for (const url of FIXTURE_URLS) { + await store.deleteAllForServer(oauthSecretServerId(url)); + } +} + describe("connection stored-auth helpers", () => { let dir: string | undefined; let prevPath: string | undefined; - afterEach(() => { + afterEach(async () => { if (prevPath === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath; resetNodeOAuthStorageCache(); + await purgeFixtureSecrets(); if (dir) { fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; @@ -105,17 +132,8 @@ describe("connection stored-auth helpers", () => { "https://example.com/mcp", "https://issuer-empty.example/mcp", "https://multi.example/mcp", - "https://nullish.example/mcp", "https://other.example/mcp", - "https://stringish.example/mcp", ]); - expect(list.servers.find((s) => s.url.includes("nullish"))).toMatchObject({ - hasTokens: false, - hasRefreshToken: false, - }); - expect(list.servers.find((s) => s.url.includes("stringish"))).toMatchObject( - { hasTokens: false, hasRefreshToken: false }, - ); expect( list.servers.find((s) => s.url.includes("issuer-empty")), ).toMatchObject({ hasTokens: false, hasRefreshToken: false }); @@ -138,6 +156,44 @@ describe("connection stored-auth helpers", () => { }); }); + it("still reports tokens after they are split into the secret store", async () => { + // Regression for the #2482 adaptation: writes split tokens out of + // oauth.json into the secret store, so a raw-file parse would report + // every entry as token-less. listStoredAuth must use the joined read. + // (Read-side plaintext migration is skipped for the non-durable memory + // store pinned in vitest.config.ts, so exercise the write-side split.) + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-")); + const file = path.join(dir, "oauth.json"); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file; + resetNodeOAuthStorageCache(); + + await writeOAuthSections( + file, + { + servers: { + "https://example.com/mcp": { + tokens: { + access_token: "a", + token_type: "Bearer", + refresh_token: "r", + }, + }, + }, + idpSessions: {}, + }, + { servers: ["https://example.com/mcp"] }, + ); + + const raw = fs.readFileSync(file, "utf8"); + expect(raw).not.toContain("access_token"); + + const list = await listStoredAuth(); + expect( + list.servers.find((s) => s.url.includes("example.com")), + ).toMatchObject({ hasTokens: true, hasRefreshToken: true }); + }); + it("clears one key and all keys", async () => { useFixture(); const cleared = await clearStoredAuth("https://example.com/mcp"); @@ -148,7 +204,7 @@ describe("connection stored-auth helpers", () => { ); const all = await clearAllStoredAuth(); - expect(all.cleared).toBe(6); + expect(all.cleared).toBe(4); list = await listStoredAuth(); expect(list.servers).toEqual([]); }); @@ -200,8 +256,9 @@ describe("connection stored-auth helpers", () => { describe("mcp auth/list and auth/clear", () => { let dir: string | undefined; - afterEach(() => { + afterEach(async () => { resetNodeOAuthStorageCache(); + await purgeFixtureSecrets(); if (dir) { fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; @@ -220,7 +277,7 @@ describe("mcp auth/list and auth/clear", () => { const body = JSON.parse(listed.stdout) as { servers: { url: string }[]; }; - expect(body.servers.length).toBe(7); + expect(body.servers.length).toBe(5); const cleared = await runMcp( ["auth/clear", "https://example.com/mcp", "--format", "json"], @@ -236,7 +293,7 @@ describe("mcp auth/list and auth/clear", () => { { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, ); expectCliSuccess(all); - expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 6 }); + expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 4 }); }); it("rejects --all without --yes when non-interactive", async () => { diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index 17877b141b..49eae65e98 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -135,6 +135,12 @@ describe("dispatchConnectionRpc", () => { }); it("opens a stream for logging/tail and wires SIGINT abort", async () => { + // Invoke only the SIGINT listener dispatch registers, rather than + // broadcasting `process.emit("SIGINT")` process-wide: `atomically` + // (loaded via core's secret-store persistence) pulls in `when-exit`, + // whose module-level SIGINT handler re-raises the signal and kills the + // vitest worker fork mid-run (#1941). + const listenersBefore = new Set(process.listeners("SIGINT")); streamDaemon.mockImplementation( async ( _params: unknown, @@ -144,7 +150,11 @@ describe("dispatchConnectionRpc", () => { type: "subscribed", uri: "test://x", }); - process.emit("SIGINT"); + const added = process + .listeners("SIGINT") + .filter((listener) => !listenersBefore.has(listener)); + expect(added).toHaveLength(1); + for (const listener of added) listener("SIGINT"); expect(opts.signal?.aborted).toBe(true); }, ); @@ -232,9 +242,16 @@ describe("dispatchConnectionRpc", () => { }); it("wires SIGINT/SIGTERM abort for the general rpc path (not just streams)", async () => { + // Same when-exit hazard as the stream test above: invoke only the + // SIGTERM listener dispatch registered, never a process-wide emit. + const listenersBefore = new Set(process.listeners("SIGTERM")); callDaemon.mockImplementation( async (_op: string, _params: unknown, opts: { signal?: AbortSignal }) => { - process.emit("SIGTERM"); + const added = process + .listeners("SIGTERM") + .filter((listener) => !listenersBefore.has(listener)); + expect(added).toHaveLength(1); + for (const listener of added) listener("SIGTERM"); expect(opts.signal?.aborted).toBe(true); return { kind: "result", result: {} }; }, diff --git a/clients/daemon-cli/src/connection/stored-auth.ts b/clients/daemon-cli/src/connection/stored-auth.ts index f6fbce994d..81808e88b0 100644 --- a/clients/daemon-cli/src/connection/stored-auth.ts +++ b/clients/daemon-cli/src/connection/stored-auth.ts @@ -1,10 +1,10 @@ -import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js"; import { clearAllOAuthClientState, getStateFilePath, NodeOAuthStorage, resetNodeOAuthStorageCache, } from "@inspector/core/auth/node/storage-node.js"; +import { readOAuthStore } from "@inspector/core/auth/node/oauth-persist-file.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; /** Same canonicalisation as one-shot `normalizeServerUrl` (avoid importing cli.ts). */ @@ -60,15 +60,12 @@ function tokenFlagsFromState(state: unknown): { async function readServersMap( statePath: string, ): Promise> { - const { readFile } = await import("node:fs/promises"); - try { - const text = await readFile(statePath, "utf8"); - const snapshot = parseOAuthPersistBlob(text); - if (snapshot?.servers && typeof snapshot.servers === "object") { - return snapshot.servers as Record; - } - } catch { - // absent / unreadable + // readOAuthStore rejoins secrets (tokens, client secrets) from the secret + // store into the snapshot — the raw oauth.json blob no longer carries them, + // so parsing the file directly would report every entry as token-less. + const snapshot = await readOAuthStore(statePath); + if (snapshot?.servers && typeof snapshot.servers === "object") { + return snapshot.servers as Record; } return {}; } diff --git a/clients/daemon-cli/vitest.config.ts b/clients/daemon-cli/vitest.config.ts index 4e44fdcfce..5cb512d3f5 100644 --- a/clients/daemon-cli/vitest.config.ts +++ b/clients/daemon-cli/vitest.config.ts @@ -25,6 +25,11 @@ export default defineConfig({ environment: "node", include: ["__tests__/**/*.test.ts"], setupFiles: [NO_RETRY_SETUP], + // OAuth tokens/client secrets are split into the selected secret store by + // the shared file persistence backend. Pin the in-memory store so + // stored-auth tests (and spawned daemons, which inherit process.env) + // never probe or write the real OS keychain on a dev machine. + env: { MCP_INSPECTOR_SECRET_STORE: "memory" }, // Shared budgets (#2323). ...TIMEOUTS, pool: "forks", diff --git a/package-lock.json b/package-lock.json index c036f14ef2..10d91d4ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,8 @@ "zod": "^4.4.3" }, "bin": { - "mcp-inspector": "clients/launcher/build/index.js" + "mcp-inspector": "clients/launcher/build/index.js", + "mcpdo": "clients/daemon-cli/build/mcp-bin.js" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1071,9 +1072,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1090,9 +1088,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1104,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1128,9 +1120,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1147,9 +1136,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1166,9 +1152,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3636,9 +3619,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3659,9 +3639,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3682,9 +3659,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3705,9 +3679,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ From 7b460971851e11209e04e600a3ee48536a8ed623 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 10:09:42 -0700 Subject: [PATCH 37/69] daemon: transparently revive dropped connections on use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A daemon-held connection's transport can die without any user action (the server expires its session, drops the SSE stream, or a stdio child exits). Previously the registry entry became a corpse: list methods silently returned empty ("Tools (0)") because ManagedListState returns [] without error when the client isn't connected, and nothing ever re-dialed. Treat the registry entry as user intent — connected until disconnect — and the InspectorClient inside it as a disposable transport artifact: - ConnectionRegistry.liveClientFor: rpc/stream ops resolve their client through a liveness check; a terminal-status client is transparently re-dialed (stored refresh token makes it silent) and swapped in place under the per-name lock. No background retry loop — revive only when an op actually needs the server. The user is involved only when re-auth genuinely needs them (auth_required: missing/expired refresh token). Revive failures keep the entry so the next op simply retries. - Extracted the dial path from connectLocked so first connect and revive share config, silent-auth semantics, and auth_required mapping. - Backstop in runRpcOnClient: a drop landing between resolve and run (e.g. while queued) now fails with connection_stale instead of lying with an empty list. - connections/show reports the live transport state (live / connecting / dormant) as a debug detail; dormant notes it reconnects on next use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/daemon-connections.test.ts | 162 ++++++++++++++++++ .../daemon-cli/src/connection/format-human.ts | 9 + clients/daemon-cli/src/daemon/connections.ts | 159 +++++++++++++---- clients/daemon-cli/src/daemon/protocol.ts | 10 ++ clients/daemon-cli/src/daemon/server.ts | 28 ++- 5 files changed, 337 insertions(+), 31 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 61bc43e3f6..7ae07340ed 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -293,6 +293,117 @@ describe("ConnectionRegistry", () => { } }); + it("liveClientFor revives a connection whose transport settled into a terminal state", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValue(undefined); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + let deadClient: unknown; + const statusSpy = vi + .spyOn(InspectorClient.prototype, "getStatus") + .mockImplementation(function (this: unknown) { + // Only the original client is dead; the revived one is live. + return this === deadClient ? "error" : "connected"; + }); + try { + await registry.connect({ + name: "r", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }); + const original = registry.clientFor("r", false); + + // Live client: no re-dial. + expect(await registry.liveClientFor("r", false)).toBe(original); + expect(connectSpy).toHaveBeenCalledTimes(1); + + // Simulate the overnight drop (server expired the session). + deadClient = original; + const revived = await registry.liveClientFor("r", false); + expect(revived).not.toBe(original); + expect(connectSpy).toHaveBeenCalledTimes(2); + // The dead client's resources were released. + expect(disconnectSpy).toHaveBeenCalledTimes(1); + // The registry entry was swapped in place — still one connection. + expect(registry.connectionCount()).toBe(1); + expect(registry.clientFor("r", false)).toBe(revived); + // Live now: no further re-dial. + expect(await registry.liveClientFor("r", false)).toBe(revived); + expect(connectSpy).toHaveBeenCalledTimes(2); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + statusSpy.mockRestore(); + } + }); + + it("revive maps a credentials failure to auth_required and keeps the entry on any failure", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValueOnce(undefined) // initial connect + .mockRejectedValueOnce( + // Revive 1: SDK token-exchange failure meaning "needs full re-auth". + new Error("prepareTokenRequest() or authorizationCode is required"), + ) + .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:443")) // revive 2: server down + .mockResolvedValueOnce(undefined); // revive 3: server back + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + let deadClient: unknown; + const statusSpy = vi + .spyOn(InspectorClient.prototype, "getStatus") + .mockImplementation(function (this: unknown) { + return this === deadClient ? "error" : "connected"; + }); + try { + await registry.connect({ + name: "r", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }); + deadClient = registry.clientFor("r", false); + + // Refresh credentials are gone: the ONLY case that involves the user. + await expect(registry.liveClientFor("r", false)).rejects.toMatchObject({ + envelope: { code: "auth_required" }, + }); + await expect(registry.liveClientFor("r", false)).rejects.toThrow( + /ECONNREFUSED/, + ); + // Both failures kept the entry — the user's intent persists… + expect(registry.connectionCount()).toBe(1); + // …so a later op simply revives once the server is reachable again. + const revived = await registry.liveClientFor("r", false); + expect(revived).not.toBe(deadClient); + expect(registry.clientFor("r", false)).toBe(revived); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + statusSpy.mockRestore(); + } + }); + it("a connect that outlives shutdown's quiesce grace tears its client down instead of leaking it", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); let releaseConnect!: () => void; @@ -803,6 +914,57 @@ describe("DaemonServer IPC", () => { ); }); + it("rpc transparently revives a connection whose transport died (end-to-end)", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-revive-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + + // Kill the daemon-held client's transport out from under the registry — + // the in-process equivalent of the server expiring the session (or a + // stdio child dying) overnight. + const registry = (server as unknown as { registry: ConnectionRegistry }) + .registry; + await registry.clientFor("stdio", false).disconnect(); + + // connections/show is passive: it reports the drop, no revive. + const shown = await callDaemon<{ transport?: string }>( + "connections/show", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + expect(shown.transport).toBe("dormant"); + + // An actual op self-heals: fresh dial, real result — never "Tools (0)". + const listed = await callDaemon<{ + kind: string; + result: { tools: unknown[] }; + }>( + "rpc", + { method: "tools/list", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(listed.kind).toBe("result"); + expect(listed.result.tools.length).toBeGreaterThan(0); + + const after = await callDaemon<{ transport?: string }>( + "connections/show", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + expect(after.transport).toBe("live"); + }); + it("rejects stream methods on rpc and rpc methods on stream", async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-ops-")); server = new DaemonServer({ dir, idleMs: 0 }); diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index cd6999cb50..9e7fc22275 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -640,6 +640,15 @@ export function formatConnectionInfoHuman( } lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`); } + // Live transport state (`connections/show` only). Dormant is informational: + // the next op transparently re-dials with stored credentials. + if (typeof connection.transport === "string") { + const detail = + connection.transport === "dormant" + ? "dormant (reconnects on next use)" + : connection.transport; + lines.push(`Transport: ${style.dim(detail)}`); + } const serverInfo = connection.serverInfo as JsonObject | undefined; if (serverInfo?.name !== undefined) { const version = diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 2ae8df8727..1002fd3962 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -3,6 +3,7 @@ import type { InspectorClientEnvironment } from "@inspector/core/mcp/types.js"; import { DEFAULT_ELICIT_CAPABILITY, eraToVersionNegotiation, + isTerminalStatus, type ElicitCapabilityMode, type InspectorClientOptions, type InspectorServerSettings, @@ -223,6 +224,134 @@ export class ConnectionRegistry { return this.connectionFor(name, requireExplicit).client; } + /** + * Like {@link clientFor}, but guarantees the returned client's transport is + * live, transparently re-dialing when it has settled into a terminal state. + * + * The registry entry represents user intent — "I connected; it's mine until + * I disconnect" — while the `InspectorClient` inside it is a disposable + * transport artifact. A long-held connection's transport can die without any + * user action (the server expires its session, drops the SSE stream, or a + * stdio child exits), so ops resolve their client through here and the dead + * client is replaced with a freshly dialed one on demand. Stored credentials + * (refresh token → new access token) make the revive silent; the user is + * only involved when re-auth genuinely needs them (`auth_required`, e.g. a + * missing/expired refresh token). No background retry loop: a connection + * nobody is using costs nothing, matching increasingly session-less servers. + * + * On revive failure the registry entry is kept — the intent persists, and + * the next op simply tries again. + */ + async liveClientFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): Promise { + const connection = this.connectionFor(name, requireExplicit); + if (!isTerminalStatus(connection.client.getStatus())) { + return connection.client; + } + return this.withNameLock(connection.name, () => + this.reviveLocked(connection.name), + ); + } + + private async reviveLocked(name: string): Promise { + this.assertOpen(); + // Re-resolve under the lock: a disconnect or replacing connect queued + // ahead of this revive changes what the name means (or removes it). + const connection = this.resolve(name, true); + if (!isTerminalStatus(connection.client.getStatus())) { + // A queued sibling op already revived it. + return connection.client; + } + const dead = connection.client; + let client: InspectorClient; + try { + client = await this.dial(connection); + } catch (error) { + if ( + error instanceof CliExitCodeError && + error.envelope?.code === "auth_required" + ) { + // Silent revive is out of credentials; only now does the user need + // to act. The front-end `connect` command runs the interactive flow. + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + `Connection '${name}' needs re-authentication (stored credentials could not be refreshed). ` + + `Run mcpdo connect for this server to sign in again. (${error.message})`, + { code: "auth_required" }, + ); + } + throw error; + } + // Free the dead client's resources (child process reaping, timers); + // best-effort, its transport is already gone. + await safeDisconnect(dead); + if (this.closed) { + // Shutdown ran while dialing; disconnectAll's snapshot already passed + // this entry, so registering the fresh client would leak it past exit. + await safeDisconnect(client); + this.assertOpen(); + } + connection.client = client; + connection.lastAccessedAt = Date.now(); + // Refresh the auth snapshot `connections/list`/`use` report — the revive + // may have rotated tokens. + const auth = await getConnectionAuthInfo(client); + if (auth) { + connection.auth = auth; + } else { + delete connection.auth; + } + return client; + } + + /** + * Build a client for a server config and connect it, using whatever + * credentials are on disk (silent: interactive login runs in the front-end, + * never here). Shared by first connect and revive. Auth failures — including + * SDK token-exchange mistakes that mean "needs a full re-auth" — map to an + * `auth_required` envelope; every failure path tears the client down. + */ + private async dial( + params: { + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + }, + signal?: AbortSignal, + ): Promise { + // Front-end authorize / auth/clear write oauth.json in another process. + // Drop the daemon's cached store so this dial re-reads disk. + resetNodeOAuthStorageCache(); + + const client = await createConnectionClient( + params.serverConfig, + params.serverSettings, + ); + + try { + // Race the connect against caller hang-up: when the requesting + // socket closes mid-dial (Ctrl-C, frontend crash) the daemon must + // not keep the attempt alive — with `--connect-timeout 0` it would + // otherwise pin `pendingConnects` (blocking idle shutdown) or + // register a connection the user cancelled. On abort the shared + // catch below tears the client down, which also cancels the + // still-in-flight connect. + await withAbort(() => client.connect(), signal, connectCancelledError); + } catch (error) { + await safeDisconnect(client); + if (isConnectionAuthRequiredError(error)) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + error instanceof Error ? error.message : String(error), + { code: "auth_required" }, + ); + } + throw error; + } + return client; + } + use(name: string): ConnectionInfo { const connection = this.resolve(name, true); this.touch(connection.name); @@ -274,35 +403,7 @@ export class ConnectionRegistry { await this.disconnectLocked(params.name); } - // Front-end authorize / auth/clear write oauth.json in another process. - // Drop the daemon's cached store so this connect re-reads disk. - resetNodeOAuthStorageCache(); - - const client = await createConnectionClient( - params.serverConfig, - params.serverSettings, - ); - - try { - // Race the connect against caller hang-up: when the requesting - // socket closes mid-dial (Ctrl-C, frontend crash) the daemon must - // not keep the attempt alive — with `--connect-timeout 0` it would - // otherwise pin `pendingConnects` (blocking idle shutdown) or - // register a connection the user cancelled. On abort the shared - // catch below tears the client down, which also cancels the - // still-in-flight connect. - await withAbort(() => client.connect(), signal, connectCancelledError); - } catch (error) { - await safeDisconnect(client); - if (isConnectionAuthRequiredError(error)) { - throw new CliExitCodeError( - EXIT_CODES.AUTH_REQUIRED, - error instanceof Error ? error.message : String(error), - { code: "auth_required" }, - ); - } - throw error; - } + const client = await this.dial(params, signal); const now = Date.now(); const auth = await getConnectionAuthInfo(client); diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 7fdb8d48c0..10abca7f6a 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -192,6 +192,16 @@ export type ConnectionShowResult = ConnectionInfo & { capabilities?: ServerCapabilities; instructions?: string; supportedVersions?: string[]; + /** + * Live transport state, `connections/show` only. The connection itself is + * user intent ("connected until I disconnect"); the transport under it is + * disposable and self-healing. `"live"` = the client session is up; + * `"connecting"` = mid-dial; `"dormant"` = the transport dropped (server + * expired the session, SSE stream closed, stdio child exited) and the next + * op will transparently re-dial with stored credentials. Debug detail, not + * something the user must act on. + */ + transport?: "live" | "connecting" | "dormant"; }; export type DaemonStatus = { diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 641f124d80..4730e5c3ab 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -430,6 +430,11 @@ export class DaemonServer { capabilities: client.getCapabilities(), instructions: client.getInstructions(), supportedVersions: client.getDiscoverResult()?.supportedVersions, + transport: isTerminalStatus(client.getStatus()) + ? "dormant" + : client.getStatus() === "connecting" + ? "connecting" + : "live", }; return { response: { id: request.id, ok: true, result }, @@ -479,7 +484,10 @@ export class DaemonServer { code: "invalid_params", }); } - const client = this.registry.clientFor(params.name, params.requireExplicit); + const client = await this.registry.liveClientFor( + params.name, + params.requireExplicit, + ); const previous = this.rpcQueues.get(client) ?? Promise.resolve(); const run = previous.then(() => this.runRpcOnClient(client, requestId, params, elicitation), @@ -503,6 +511,19 @@ export class DaemonServer { elicitation: ElicitationChannel, ): Promise { const methodArgs = stripConnectionFields(params); + // Backstop against the silent-empty class: `runMethod`'s list states + // return `[]` without error when the client isn't connected, which would + // render as "Tools (0)" for a connection that actually dropped. The + // resolve above revived a dead client, but a drop can still land between + // that and here (e.g. while queued behind a long op) — fail honestly and + // let a retry revive it. + if (isTerminalStatus(client.getStatus())) { + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "The connection dropped before this command could run; re-run the command to reconnect.", + { code: "connection_stale" }, + ); + } const unwire = wireElicitationBridge(client, elicitation, requestId); let outcome; try { @@ -541,7 +562,10 @@ export class DaemonServer { code: "invalid_params", }); } - const client = this.registry.clientFor(params.name, params.requireExplicit); + const client = await this.registry.liveClientFor( + params.name, + params.requireExplicit, + ); const methodArgs = stripConnectionFields(params); const outcome = await runMethod(client, methodArgs); if (outcome.kind !== "stream") { From 66df4225ed2ce94ad856062056c5bd4b9e1c5dab Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 11:09:37 -0700 Subject: [PATCH 38/69] mcpdo: show catalog/config provenance and clearer servers/show errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - servers/list and servers/show output the resolved source (catalog|config + path) as a Source: line (human) and source key (JSON), via a shared resolveServerListSource() helper in the cli handlers. - servers/show [name] is now optional: on an interactive TTY (or with MCP_ALLOW_DEFAULT_CONNECTION=1) it falls back to the MRU connection's entry name; non-interactive shells and no-MRU cases get errors that explain why a name is required. - Entry-not-found errors now say which file was searched, and when the name was inferred from the MRU connection the error explains that connections and catalog entries are separate — the MRU connection may come from a different catalog than the current shell's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/cli/__tests__/servers-list.test.ts | 26 +++++ clients/cli/src/handlers/servers-list.ts | 23 ++++ .../__tests__/format-connection.test.ts | 21 +++- .../daemon-cli/__tests__/mcp-coverage.test.ts | 61 ++++++++++ .../src/connection/format-connection.ts | 27 ++++- .../daemon-cli/src/connection/format-human.ts | 11 ++ clients/daemon-cli/src/connection/mcp.ts | 106 +++++++++++++++++- 7 files changed, 262 insertions(+), 13 deletions(-) diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 7f83f57459..b425536252 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -9,6 +9,7 @@ import { expectCliSuccess } from "./helpers/assertions.js"; import { annotateServerEntriesWithConnections, listServerEntries, + resolveServerListSource, sanitizeServerConfig, sanitizeServerSettings, showServerEntry, @@ -100,6 +101,31 @@ describe("annotateServerEntriesWithConnections", () => { }); }); +describe("resolveServerListSource", () => { + it("reports catalog (writable) vs config (read-only) with the resolved path", () => { + expect(resolveServerListSource({ catalogPath: "/tmp/cat.json" })).toEqual({ + kind: "catalog", + path: "/tmp/cat.json", + }); + expect(resolveServerListSource({ configPath: "/tmp/conf.json" })).toEqual({ + kind: "config", + path: "/tmp/conf.json", + }); + }); + + it("falls back to the default writable catalog when no source is given", () => { + const source = resolveServerListSource({}); + expect(source?.kind).toBe("catalog"); + expect(source?.path).toMatch(/mcp\.json$/); + }); + + it("is null for ad-hoc targets (no list source)", () => { + expect( + resolveServerListSource({ target: ["https://example.com/mcp"] }), + ).toBeNull(); + }); +}); + describe("listServerEntries / --method servers/list", () => { let configPath: string | undefined; diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index 64b73f3a0c..76a02603c5 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -5,7 +5,9 @@ import type { import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js"; import { loadServerEntries, + resolveServerSource, selectServerEntry, + withDefaultCatalogPath, type ServerLoadOptions, } from "@inspector/core/mcp/node/index.js"; @@ -30,6 +32,27 @@ export type ConnectionListRef = { isMru?: boolean; }; +/** + * Where a server list came from: the writable catalog (default + * `~/.mcp-inspector/mcp.json`, or `--catalog` / `MCP_CATALOG_PATH`) or a + * read-only `--config` file. Surfaced by `servers/list` so users working + * across shells with different catalog env vars can see which file produced + * the entries. `null` for ad-hoc targets (no list source). + */ +export type ServerListSource = { kind: "catalog" | "config"; path: string }; + +/** + * Resolve the source `listServerEntries` would read for these options, + * applying the same default-catalog fallback. + */ +export function resolveServerListSource( + serverOptions: ServerLoadOptions = {}, +): ServerListSource | null { + const source = resolveServerSource(withDefaultCatalogPath(serverOptions)); + if (!source) return null; + return { kind: source.writable ? "catalog" : "config", path: source.path }; +} + /** * Mark catalog entries that have a live connection with the same name. * Does not mutate `entries`. diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index e3f22f4a4a..e87ebbe91e 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -25,7 +25,7 @@ import { } from "../src/connection/format-human.js"; import { writeConnectionOutput } from "../src/connection/format-connection.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import { createStyle } from "@inspector/cli/style.js"; +import { createStyle, PLAIN } from "@inspector/cli/style.js"; describe("format-human", () => { it("formats tools with schema variants and empty list", () => { @@ -311,6 +311,18 @@ describe("format-human", () => { formatAuthListHuman({ oauthStatePath: "/tmp/x", servers: [] }), ).toContain("(none)"); expect(formatServersListHuman([])).toContain("(none)"); + expect( + formatServersListHuman([], PLAIN, { + kind: "catalog", + path: "/home/u/.mcp-inspector/mcp.json", + }), + ).toContain("Source: catalog /home/u/.mcp-inspector/mcp.json"); + expect( + formatServersListHuman([], PLAIN, { + kind: "config", + path: "./mcp.json", + }), + ).toContain("Source: config ./mcp.json"); expect( formatServersListHuman([{ name: "s", type: "stdio", detail: "x" }]), ).toContain("`s`"); @@ -325,6 +337,13 @@ describe("format-human", () => { }, ]), ).toMatch(/@s \(MRU\)/); + expect( + formatServerShowHuman( + { name: "s", type: "stdio", detail: "x", config: {} }, + PLAIN, + { kind: "catalog", path: "/tmp/cat.json" }, + ), + ).toContain("Source: catalog /tmp/cat.json"); expect( formatServerShowHuman({ name: "s", diff --git a/clients/daemon-cli/__tests__/mcp-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-coverage.test.ts index 4f48c50394..7bfa305025 100644 --- a/clients/daemon-cli/__tests__/mcp-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-coverage.test.ts @@ -6,6 +6,7 @@ import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-se import { runMcp } from "./helpers/mcp-runner.js"; import { createSampleTestConfig, + createTestConfig, deleteConfigFile, } from "../../cli/__tests__/helpers/fixtures.js"; import { @@ -279,6 +280,35 @@ describe("mcp.ts coverage", () => { { env: e, timeout: 20000 }, ); expectCliSuccess(show); + // Entry provenance rides along, mirroring servers/list. + expect(JSON.parse(show.stdout).source).toMatchObject({ kind: "config" }); + + // No name: falls back to the MRU connection's entry (test-stdio is + // connected above and MCP_ALLOW_DEFAULT_CONNECTION opts non-TTY in). + const showMru = await runMcp( + ["servers/show", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(showMru); + expect(JSON.parse(showMru.stdout).name).toBe("test-stdio"); + + // MRU-inferred name missing from the shell's source: the error must + // explain the name came from the MRU connection, not look like a typo. + const otherConfig = createTestConfig({ + mcpServers: { unrelated: { type: "stdio", command: "true" } }, + }); + try { + const crossCatalog = await runMcp( + ["servers/show", "--config", otherConfig], + { env: e, timeout: 20000 }, + ); + expectCliFailure(crossCatalog); + expect(crossCatalog.stderr).toMatch( + /most-recently-used connection 'test-stdio' has no entry in config/, + ); + } finally { + deleteConfigFile(otherConfig); + } // Skills support is optional; the default test server may not advertise // it. Either way, the RPC action itself should run (not a usage error). @@ -476,6 +506,37 @@ describe("mcp.ts coverage", () => { } }); + it("servers/show without a name explains the MRU rules instead of a bare parse error", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + // Non-interactive without the default-connection opt-in: name required. + const strict = await runMcp(["servers/show", "--config", configPath], { + env: { ...e, MCP_ALLOW_DEFAULT_CONNECTION: "" }, + timeout: 20000, + }); + expectCliFailure(strict); + expect(strict.stderr).toMatch(/requires an entry name/); + + // Opted in but nothing connected (no daemon): no MRU to infer from. + const noMru = await runMcp(["servers/show", "--config", configPath], { + env: e, + timeout: 20000, + }); + expectCliFailure(noMru); + expect(noMru.stderr).toMatch(/no most-recently-used connection/); + + // Explicit unknown name: keep the core message but say which file was + // searched, so a cross-catalog mismatch is self-explanatory. + const unknown = await runMcp( + ["servers/show", "no-such-entry", "--config", configPath], + { env: e, timeout: 20000 }, + ); + expectCliFailure(unknown); + expect(unknown.stderr).toMatch(/Server 'no-such-entry' not found/); + expect(unknown.stderr).toContain(`(config ${configPath}`); + }); + it("connections/list and daemon status do not auto-spawn the daemon", async () => { const e = env(); const listed = await runMcp(["connections/list", "--format", "json"], { diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 45898725f4..3f69e335be 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -69,8 +69,18 @@ export type ConnectionWriteKind = exitCode?: number; } | { kind: "stream-event"; data: unknown } - | { kind: "servers/list"; servers: unknown[] } - | { kind: "servers/show"; server: JsonObject } + | { + kind: "servers/list"; + servers: unknown[]; + /** Which file produced the entries (writable catalog vs read-only config). */ + source?: { kind: "catalog" | "config"; path: string }; + } + | { + kind: "servers/show"; + server: JsonObject; + /** Which file produced the entry (writable catalog vs read-only config). */ + source?: { kind: "catalog" | "config"; path: string }; + } | { kind: "connections/list"; connections: unknown[] } | { kind: "connection"; connection: ConnectionInfo | JsonObject } | { kind: "disconnect"; name: string } @@ -165,9 +175,14 @@ function jsonPayload(payload: ConnectionWriteKind): unknown { case "stream-event": return payload.data; case "servers/list": - return { servers: payload.servers }; + return { + servers: payload.servers, + ...(payload.source && { source: payload.source }), + }; case "servers/show": - return payload.server; + return payload.source + ? { ...payload.server, source: payload.source } + : payload.server; case "connections/list": return { connections: payload.connections }; case "connection": @@ -213,9 +228,9 @@ function humanPayload(payload: ConnectionWriteKind, style: Style): string { case "stream-event": return formatStreamEventHuman(payload.data, style); case "servers/list": - return formatServersListHuman(payload.servers, style); + return formatServersListHuman(payload.servers, style, payload.source); case "servers/show": - return formatServerShowHuman(payload.server, style); + return formatServerShowHuman(payload.server, style, payload.source); case "connections/list": return formatConnectionsListHuman(payload.connections, style); case "connection": diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 9e7fc22275..077f360123 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -537,8 +537,14 @@ export function formatEmaStatusHuman( export function formatServersListHuman( servers: unknown[], style: Style = PLAIN, + source?: { kind: "catalog" | "config"; path: string }, ): string { const lines = [heading(style, `Servers (${servers.length}):`)]; + // Say where the entries came from — shells with different --catalog / + // MCP_CATALOG_PATH / --config see different lists from the same daemon. + if (source) { + lines.push(`Source: ${style.dim(`${source.kind} ${source.path}`)}`); + } for (const raw of servers) { const s = raw as JsonObject; const connectionName = @@ -560,6 +566,7 @@ export function formatServersListHuman( export function formatServerShowHuman( server: JsonObject, style: Style = PLAIN, + source?: { kind: "catalog" | "config"; path: string }, ): string { const name = String(server.name ?? "?"); const type = String(server.type ?? "?"); @@ -575,6 +582,10 @@ export function formatServerShowHuman( return [ header, detail ? style.dim(detail) : style.dim("(no detail)"), + // Same provenance line as servers/list — which file the entry came from. + ...(source + ? [`Source: ${style.dim(`${source.kind} ${source.path}`)}`] + : []), JSON.stringify(body, null, 2), ].join("\n"); } diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 0aeb45af4e..2e95d7a43a 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -29,6 +29,8 @@ import type { import { annotateServerEntriesWithConnections, listServerEntries, + resolveServerListSource, + type ServerListSource, showServerEntry, summarizeServerConfig, } from "@inspector/cli/handlers/servers-list.js"; @@ -76,6 +78,77 @@ function isDaemonUnreachable(error: unknown): boolean { ); } +/** + * `servers/show` with no name falls back to the MRU connection's entry name, + * under the same non-interactive gate as MRU connection targeting: an agent + * shell must name the entry explicitly. When there is no MRU (no daemon, or + * nothing connected), say so — a bare Commander "missing required argument" + * doesn't tell the user why a name is needed. + */ +async function resolveMruEntryName(): Promise { + if (requireExplicitConnection()) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "servers/show requires an entry name in non-interactive mode. Pass one (see mcpdo servers/list).", + { code: "server_name_required" }, + ); + } + let connections: ConnectionInfo[] = []; + try { + const result = await callDaemon<{ connections: ConnectionInfo[] }>( + "connections/list", + {}, + ); + connections = result.connections; + } catch (error) { + if (!isDaemonUnreachable(error)) throw error; + } + const mru = connections.find((c) => c.isMru); + if (!mru) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "No entry name given and there is no most-recently-used connection to infer one from. Pass a catalog entry name (see mcpdo servers/list).", + { code: "server_name_required" }, + ); + } + return mru.name; +} + +/** + * The core "not found" error is source-agnostic by design; here we know both + * where the list came from and whether the user typed the name. An explicit + * name gets the source appended; an MRU-inferred name gets a full explanation, + * because "Server 'X' not found" is baffling when the user never typed X — + * connections are daemon-global while the catalog is per-shell, so the MRU + * connection's entry may simply not exist in this shell's catalog. + */ +function describeServerShowNotFound( + error: unknown, + entryName: string, + inferredFromMru: boolean, + source: ServerListSource | null, +): unknown { + if ( + !(error instanceof Error) || + !error.message.startsWith(`Server '${entryName}' not found`) + ) { + return error; + } + const where = source + ? `${source.kind} ${source.path}` + : "the resolved server list"; + if (inferredFromMru) { + return new CliExitCodeError( + EXIT_CODES.USAGE, + `The most-recently-used connection '${entryName}' has no entry in ${where}. Connections and catalog entries are separate — it may have been connected ad-hoc or from a different catalog. Pass an entry name (see mcpdo servers/list).`, + { code: "server_not_found" }, + ); + } + return new CliExitCodeError(EXIT_CODES.USAGE, `${error.message} (${where})`, { + code: "server_not_found", + }); +} + /** Commander help/version exits — text already written; not real failures. */ function isCommanderDisplayOnly(error: unknown): boolean { if (error == null || typeof error !== "object") return false; @@ -199,10 +272,12 @@ export async function runMcp(argv?: string[]): Promise { .action(async () => { const opts = program.opts(); const envCatalog = process.env.MCP_CATALOG_PATH; - const entries = await listServerEntries({ + const serverOptions = { catalogPath: opts.catalog?.trim() || envCatalog, configPath: opts.config?.trim() || undefined, - }); + }; + const entries = await listServerEntries(serverOptions); + const source = resolveServerListSource(serverOptions); let connections: ConnectionInfo[] = []; try { const result = await callDaemon<{ connections: ConnectionInfo[] }>( @@ -216,6 +291,7 @@ export async function runMcp(argv?: string[]): Promise { await writeConnectionOutput(outOpts(opts), { kind: "servers/list", servers: annotateServerEntriesWithConnections(entries, connections), + ...(source && { source }), }); }); @@ -224,17 +300,35 @@ export async function runMcp(argv?: string[]): Promise { .description( "Show one catalog/config entry in detail (no MCP connection; secrets redacted)", ) - .argument("", "Catalog entry name") - .action(async (name: string) => { + .argument( + "[name]", + "Catalog entry name (defaults to the MRU connection's entry on an interactive TTY)", + ) + .action(async (name: string | undefined) => { const opts = program.opts(); const envCatalog = process.env.MCP_CATALOG_PATH; - const entry = await showServerEntry(name, { + const serverOptions = { catalogPath: opts.catalog?.trim() || envCatalog, configPath: opts.config?.trim() || undefined, - }); + }; + const explicitName = stripAt(name?.trim() || undefined); + const entryName = explicitName ?? (await resolveMruEntryName()); + const source = resolveServerListSource(serverOptions); + let entry; + try { + entry = await showServerEntry(entryName, serverOptions); + } catch (error) { + throw describeServerShowNotFound( + error, + entryName, + explicitName === undefined, + source, + ); + } await writeConnectionOutput(outOpts(opts), { kind: "servers/show", server: entry, + ...(source && { source }), }); }); From 6da088cc0406eaed24f33be859067e1b9dc88b45 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 12:51:47 -0700 Subject: [PATCH 39/69] mcpdo: trigger eval for the shipped skill, and a description that measures better MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds skills:eval:mcpdo — a trigger eval for the PRODUCT skill skills/mcpdo, reusing the dev harness's exported machinery (runPrompt, formatReport, parseSkill, validateEvalCases). Each sample runs in a sandbox project under ~/.cache containing only the copied skill: agents discover project skills from /.claude/skills, so a run at the repo root cannot see skills/mcpdo at all and would load ten dev skills a user doesn't have. Eval cases live at clients/daemon-cli/evals/evals.json, NOT inside skills/mcpdo: the skill directory is a published artifact (npm files) and the skill IS the directory — dev-only eval data doesn't belong in the payload. Like skills:eval, this is manual-only, never a gate (metered model calls, hit-rate measurement). Measured baseline (claude, RUNS=3) quantified the positioning gap: action prompts fired 100%, but 'what MCP servers am I connected to?' 33% and 'do I have MCP tools besides your built-ins?' 0% — agents treated mcpdo as a task tool, not a source of capabilities. Rewrote the description to lead with capability framing; re-measured 8/8 cases at 100% on both claude and copilot, negatives (over-trigger guards) still clean. Also declares disable-model-invocation: false explicitly, matching the repo's skill convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 34 +++++ package.json | 3 +- scripts/skill-eval-mcpdo.mjs | 206 ++++++++++++++++++++++++++++ skills/mcpdo/SKILL.md | 3 +- 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 clients/daemon-cli/evals/evals.json create mode 100644 scripts/skill-eval-mcpdo.mjs diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json new file mode 100644 index 0000000000..2af729ec96 --- /dev/null +++ b/clients/daemon-cli/evals/evals.json @@ -0,0 +1,34 @@ +[ + { + "prompt": "What MCP servers am I connected to?", + "expect": "mcpdo" + }, + { + "prompt": "Call the echo tool on the everything MCP server and show me the result.", + "expect": "mcpdo" + }, + { + "prompt": "Do I have access to any MCP tools besides your built-in ones?", + "expect": "mcpdo" + }, + { + "prompt": "List the resources available on the MCP server I connected to earlier.", + "expect": "mcpdo" + }, + { + "prompt": "Use mcpdo to list the tools on the everything server.", + "expect": "mcpdo" + }, + { + "prompt": "What does this regex do? /^\\d{3}-\\d{4}$/", + "expect": null + }, + { + "prompt": "How do I write a simple HTTP server in Node.js?", + "expect": null + }, + { + "prompt": "Rename the variable `foo` to `bar` in this snippet: const foo = 1; console.log(foo);", + "expect": null + } +] diff --git a/package.json b/package.json index e8fbdd3950..27e90a8ab2 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ "pack:verify": "node scripts/install-smoke-browser.mjs chromium && node scripts/pack-and-verify.mjs", "prepack": "npm run build", "postinstall": "node scripts/install-clients.mjs", - "skills:eval": "node scripts/skill-eval.mjs" + "skills:eval": "node scripts/skill-eval.mjs", + "skills:eval:mcpdo": "node scripts/skill-eval-mcpdo.mjs" }, "dependencies": { "@hono/node-server": "^2.0.12", diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs new file mode 100644 index 0000000000..7dc1c279c8 --- /dev/null +++ b/scripts/skill-eval-mcpdo.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// Trigger eval for the PRODUCT skill `skills/mcpdo` (the mcpdo connection CLI). +// +// `skills:eval` measures the dev-workflow skills in `.claude/skills`. The +// mcpdo skill is a different animal: it SHIPS in the npm package and is +// installed into a *user's* skills directory, so measuring it inside this +// repo's checkout would measure the wrong environment twice over — +// +// - agents discover project skills from `/.claude/skills`, so a run +// with `cwd` at the repo root cannot see `skills/mcpdo` at all, and +// - the repo checkout loads AGENTS.md and ten dev skills, none of which a +// user of mcpdo has in context. +// +// So each sample runs in a SANDBOX project: a fresh directory outside the +// repo containing only `.claude/skills/mcpdo` (a copy of the shipped skill, +// evals excluded). That is the closest headless approximation of "a user with +// the mcpdo skill installed asks their agent something". +// +// The sandbox lives under `~/.cache`, not `os.tmpdir()`, for two reasons that +// are both about flags `runPrompt` already passes: the Copilot run carries +// `--disallow-temp-dir`, and a sandbox *inside* the repo would be walked up +// past — the agent would resolve the repo as the project root and load the +// dev skills instead of the sandbox's. +// +// What a trigger case proves — and does not. A hit means only "the agent +// loaded the mcpdo skill for this prompt". The case set therefore skews +// toward UNPROMPTED-RECOGNITION prompts (no mention of mcpdo: "what MCP +// servers am I connected to?") where the decision to reach for the skill is +// the entire measurement, plus negatives that guard against a description +// broadened into firing on every prompt containing "server" or "tools". A +// task-framed "use mcpdo to ..." prompt is near-guaranteed to fire and earns +// one smoke case, no more. Whether the agent then runs the RIGHT mcpdo +// commands is a separate (behavior) measurement this script does not make. +// +// Like `skills:eval`, this is NOT part of `validate`, `local:gate`, or CI: +// it spends metered model calls and the measurement is a hit rate over +// samples. Run it before and after editing `skills/mcpdo/SKILL.md`, and +// compare the same cases. +// +// Usage: +// npm run skills:eval:mcpdo +// RUNS=5 THRESHOLD=0.8 npm run skills:eval:mcpdo +// AGENT=copilot npm run skills:eval:mcpdo + +import { + cpSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { AGENTS, formatReport, runPrompt } from "./skill-eval.mjs"; +import { parseSkill, validateEvalCases } from "./lib/skill-manifest.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SKILL_DIR = path.join(ROOT, "skills", "mcpdo"); +// The cases live OUTSIDE the skill directory, deliberately: `skills/mcpdo` is +// a published artifact (npm `files`, installed into user skill dirs), and the +// skill IS the directory — eval data inside it would ship to every user and +// pollute what an agent may read. Dev-only measurement data belongs with the +// package that owns the skill, not in its payload. (The dev-workflow skills +// under `.claude/skills` keep evals inline because those directories never +// leave the repo.) +const EVALS_FILE = path.join( + ROOT, + "clients", + "daemon-cli", + "evals", + "evals.json", +); +const SKILL_NAME = "mcpdo"; + +const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); +const RUNS = Number(process.env.RUNS ?? 3); +const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4); +const AGENT = process.env.AGENT ?? "claude"; + +/** + * Build the sandbox project one sample set runs in. + * + * The skill is COPIED rather than symlinked: a symlink would resolve back + * inside the repo, and whether an agent's project-root detection follows it + * is exactly the kind of version-dependent behavior a measurement should not + * sit on. + * + * @returns {string} The sandbox directory. + */ +export function makeSandbox() { + const dir = path.join( + os.homedir(), + ".cache", + "mcpdo-skill-eval", + `${process.pid}-${randomBytes(4).toString("hex")}`, + ); + const dest = path.join(dir, ".claude", "skills", SKILL_NAME); + mkdirSync(dest, { recursive: true }); + cpSync(SKILL_DIR, dest, { recursive: true }); + // A README so a human finding a leaked sandbox knows what it was. + writeFileSync( + path.join(dir, "README.md"), + "Scratch project for `npm run skills:eval:mcpdo`; safe to delete.\n", + ); + return dir; +} + +/** Load and validate the committed cases. */ +export function loadCases() { + const skill = parseSkill( + SKILL_NAME, + readFileSync(path.join(SKILL_DIR, "SKILL.md"), "utf8"), + ); + if (skill.errors.length > 0) { + throw new Error(`skills/mcpdo/SKILL.md does not parse: ${skill.errors[0]}`); + } + if (!skill.modelInvoked) { + throw new Error( + "skills/mcpdo is not model-invoked; a trigger eval of it measures nothing", + ); + } + const evalsFile = EVALS_FILE; + const cases = JSON.parse(readFileSync(evalsFile, "utf8")); + const invalid = validateEvalCases(SKILL_NAME, cases, new Set([SKILL_NAME])); + if (invalid.length > 0) { + throw new Error(`skills/mcpdo/evals/evals.json: ${invalid.join("; ")}`); + } + return cases; +} + +async function pool(items, n, fn) { + const out = new Array(items.length); + let i = 0; + await Promise.all( + Array.from({ length: Math.min(n, items.length) }, async () => { + while (i < items.length) { + const idx = i++; + out[idx] = await fn(items[idx]); + } + }), + ); + return out; +} + +async function main() { + if (!AGENTS.includes(AGENT)) { + console.error( + `skills:eval:mcpdo — unknown AGENT \`${AGENT}\`; known: ${AGENTS.join(", ")}`, + ); + process.exit(1); + } + if (!Number.isFinite(THRESHOLD) || THRESHOLD < 0 || THRESHOLD > 1) { + console.error( + `skills:eval:mcpdo — THRESHOLD must be a number in [0, 1] (got ${process.env.THRESHOLD}).`, + ); + process.exit(1); + } + const cases = loadCases().map((c) => ({ ...c, from: SKILL_NAME })); + const sandbox = makeSandbox(); + console.log( + `skills:eval:mcpdo — ${cases.length} cases x ${RUNS} runs, agent ${AGENT}, sandbox ${sandbox}`, + ); + + const samples = cases.flatMap((c) => Array.from({ length: RUNS }, () => c)); + try { + const results = await pool(samples, CONCURRENCY, async (c) => { + const invoked = await runPrompt(c.prompt, { + cwd: sandbox, + agent: AGENT, + maxTurns: 1, + }); + return { c, invoked }; + }); + // `ours` is just {mcpdo}: a negative case asserts THIS skill stayed + // quiet. The sandbox has no other project skill, but the contributor's + // ~/.claude skills are still visible to the run and are not ours to + // assert about. + const { lines, failed } = formatReport( + cases, + results, + new Set([SKILL_NAME]), + { + threshold: THRESHOLD, + chainThreshold: 0.5, + chainMaxTurns: 1, + agent: AGENT, + }, + ); + for (const line of lines) console.log(line); + process.exit(failed > 0 ? 1 : 0); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((e) => { + console.error(e.message ?? e); + process.exit(1); + }); +} diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 233df0c157..87879c82b7 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -1,6 +1,7 @@ --- name: mcpdo -description: Use the mcpdo CLI to connect to Model Context Protocol (MCP) servers and run tools, read resources, list prompts, and more from the command line or from an agent's shell. Use this skill whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. +description: Access MCP (Model Context Protocol) servers and their tools, resources, and prompts through the mcpdo CLI — connections it holds extend your capabilities alongside any built-in MCP support. Use this skill for any question or task about MCP servers, connections, or tools (e.g. "what MCP servers am I connected to?", "what MCP tools do I have?"), and whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. +disable-model-invocation: false --- # mcpdo — MCP Inspector connection CLI From 7194349f9ecae70b733ab4f3afde86efe4544c2a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 13:48:54 -0700 Subject: [PATCH 40/69] =?UTF-8?q?mcpdo:=20behavior=20eval=20=E2=80=94=20me?= =?UTF-8?q?asure=20the=20commands=20agents=20actually=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends skills:eval:mcpdo with behavior cases ("kind": "behavior", an explicit discriminator on every case): did the agent drive mcpdo correctly, not just load the skill? Each sample runs against a hermetic world — private daemon dir + minted token, throwaway storage, a catalog holding exactly one entry (test-stdio, the repo's composable stdio test server in its default composition) — so samples share no MRU or connection state and never touch a contributor's real daemon. Observation is a recording shim, not stream parsing: a PATH-injected mcpdo wrapper (lib/mcpdo-eval-shim.mjs) spawns the real build, tees stdin/stdout/ stderr through verbatim, and appends a timestamped transcript per invocation {argv, exit, start, end, events}. Parsing shell strings out of agent event streams would badly reimplement sh; argv arrays are mechanical. Output is captured in whatever format the agent asked for — the shim injects nothing. Scoring (lib/mcpdo-eval-matchers.mjs): expectCalls is an ordered subsequence of structured matchers (cmd, connection, tool, args subset, exit, result JSON-paths, stdoutMatch). Every CLI spelling of the same call normalizes to one parse (@name / --connection / --conn; a:=2 / --tool-arg a=2 / inline JSON / --tool-args-json), values compare canonically ("2" matches 2), and connect matches its connection positionally. Unknown matcher keys are validation errors, not silent no-ops. Shell containment is command-scoped approval, probed live on both CLIs: Claude --allowedTools "Bash(mcpdo *)" (unapproved effectful commands fail fast into permission_denials), Copilot --allow-tool 'shell(mcpdo:*)' (fast non-fatal denied errors). Copilot's --available-tools is deliberately NOT used: its availability names differ from its pattern names, and filtering the shell tool out makes the model fabricate command output — measured, not hypothesized. runPrompt in the dev harness gains two additive seams (env, agentArgsFn — replacement, since --deny-tool shell cannot be retracted by appending); existing tests unaffected. Two cases land: connect→tools/list, and add-2-and-3 asserting connection + tool (get_sum) + args {a:2,b:3} + a 5 in stdout. First live measurement: claude 2/2; copilot connect→list 1/1, add 2/3 (the miss was the 14-call turn budget cutting an otherwise-correct run) — passing BEHAVIOR_THRESHOLD 0.5. Behavior cases are POSIX-only (the shim installs as a #!/bin/sh wrapper) and, like everything in this runner, manual-only — never a gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 38 +++ scripts/lib/mcpdo-eval-matchers.mjs | 373 +++++++++++++++++++++++ scripts/lib/mcpdo-eval-matchers.test.mjs | 283 +++++++++++++++++ scripts/lib/mcpdo-eval-shim.mjs | 131 ++++++++ scripts/lib/mcpdo-eval-shim.test.mjs | 90 ++++++ scripts/skill-eval-mcpdo.mjs | 360 +++++++++++++++++++++- scripts/skill-eval.mjs | 12 +- 7 files changed, 1270 insertions(+), 17 deletions(-) create mode 100644 scripts/lib/mcpdo-eval-matchers.mjs create mode 100644 scripts/lib/mcpdo-eval-matchers.test.mjs create mode 100644 scripts/lib/mcpdo-eval-shim.mjs create mode 100644 scripts/lib/mcpdo-eval-shim.test.mjs diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json index 2af729ec96..a3f642b5f5 100644 --- a/clients/daemon-cli/evals/evals.json +++ b/clients/daemon-cli/evals/evals.json @@ -1,34 +1,72 @@ [ { + "kind": "trigger", "prompt": "What MCP servers am I connected to?", "expect": "mcpdo" }, { + "kind": "trigger", "prompt": "Call the echo tool on the everything MCP server and show me the result.", "expect": "mcpdo" }, { + "kind": "trigger", "prompt": "Do I have access to any MCP tools besides your built-in ones?", "expect": "mcpdo" }, { + "kind": "trigger", "prompt": "List the resources available on the MCP server I connected to earlier.", "expect": "mcpdo" }, { + "kind": "trigger", "prompt": "Use mcpdo to list the tools on the everything server.", "expect": "mcpdo" }, { + "kind": "trigger", "prompt": "What does this regex do? /^\\d{3}-\\d{4}$/", "expect": null }, { + "kind": "trigger", "prompt": "How do I write a simple HTTP server in Node.js?", "expect": null }, { + "kind": "trigger", "prompt": "Rename the variable `foo` to `bar` in this snippet: const foo = 1; console.log(foo);", "expect": null + }, + { + "kind": "behavior", + "prompt": "Connect to the test-stdio MCP server and list its tools.", + "expectCalls": [ + { + "cmd": "connect", + "connection": "test-stdio" + }, + { + "cmd": "tools/list", + "connection": "test-stdio" + } + ] + }, + { + "kind": "behavior", + "prompt": "Use the test-stdio MCP server to add the numbers 2 and 3, and tell me the result.", + "expectCalls": [ + { + "cmd": "tools/call", + "connection": "test-stdio", + "tool": "get_sum", + "args": { + "a": 2, + "b": 3 + }, + "stdoutMatch": "5" + } + ] } ] diff --git a/scripts/lib/mcpdo-eval-matchers.mjs b/scripts/lib/mcpdo-eval-matchers.mjs new file mode 100644 index 0000000000..397ea05e53 --- /dev/null +++ b/scripts/lib/mcpdo-eval-matchers.mjs @@ -0,0 +1,373 @@ +// Structured matchers for mcpdo behavior evals (`skills:eval:mcpdo`). +// +// A behavior case asserts WHAT an agent did with mcpdo, from the transcript +// records the eval shim (`mcpdo-eval-shim.mjs`) writes: one JSON line per +// invocation, `{ argv, exit, start, end, events }`. Everything here works on +// parsed argv ARRAYS and captured output — never on re-parsed shell strings, +// which would re-implement the shell's quoting rules badly and drift from +// what actually ran. +// +// Matcher semantics are deliberately loose where the CLI is generous. mcpdo +// accepts the same tool call as `tools/call get_sum a:=2 b:=3`, +// `tools/call --tool-name get_sum --tool-arg a=2 b=3`, or +// `tools/call get_sum '{"a":2,"b":3}'`, with the connection as `@name`, as +// `--connection name`, or implicit via MRU. A case should pass for every +// correct spelling and fail for a wrong tool, wrong argument, or wrong +// server — so parsing normalizes all spellings into one shape before +// matching, and values compare canonically (`"2"` matches `2`). + +/** + * Global and per-command flags that consume exactly one following token. + * + * Deliberately a fixed list rather than a "flags eat the next token" + * heuristic: boolean flags like `--task` would otherwise swallow the tool + * name. An unknown value-flag degrades softly — its value shows up as a + * stray positional, which no matcher field reads. + */ +const VALUE_FLAGS = new Set([ + "--format", + "--connection", + "--conn", + "--catalog", + "--config", + "--tool-name", + "--tool-args-json", + "--uri", + "--transport", + "--cwd", + "--connect-timeout", + "--era", + "--elicit", + "-e", +]); + +/** + * Variadic flags (``): consume following `key=value` tokens until + * one stops looking like a pair. + */ +const VARIADIC_FLAGS = new Set(["--metadata", "--tool-arg", "--tool-metadata"]); + +/** JSON.parse when the text parses, the raw string otherwise. */ +function looseParse(text) { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** + * Parse one mcpdo invocation's argv (everything after `mcpdo`) into the + * fields matchers assert on. + * + * @param {string[]} argv + * @returns {{ cmd: string | null, connection: string | null, tool: string | + * null, args: Record, positionals: string[] }} + */ +export function parseMcpdoArgv(argv) { + let connection = null; + let toolNameFlag = null; + const args = {}; + const positionals = []; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (VARIADIC_FLAGS.has(token)) { + const pairs = []; + while ( + i + 1 < argv.length && + !argv[i + 1].startsWith("-") && + argv[i + 1].includes("=") + ) { + pairs.push(argv[++i]); + } + if (token === "--tool-arg") { + for (const pair of pairs) { + const eq = pair.indexOf("="); + args[pair.slice(0, eq)] = looseParse(pair.slice(eq + 1)); + } + } + continue; + } + if (VALUE_FLAGS.has(token)) { + const value = argv[++i]; + if (token === "--connection" || token === "--conn") connection = value; + if (token === "--tool-name") toolNameFlag = value; + if (token === "--tool-args-json") { + const parsed = looseParse(value); + if (parsed !== null && typeof parsed === "object") { + Object.assign(args, parsed); + } + } + continue; + } + if (token.startsWith("--")) continue; // boolean flag + if (token.startsWith("@") && token.length > 1) { + // Positional `@name` selects the connection wherever it appears. + if (connection === null) connection = token.slice(1); + continue; + } + positionals.push(token); + } + + let cmd = positionals[0] ?? null; + let rest = positionals.slice(1); + if (cmd === "daemon" && rest.length > 0) { + // `daemon stop` etc. — the subcommand is part of what a case asserts. + cmd = `daemon ${rest[0]}`; + rest = rest.slice(1); + } + + let tool = toolNameFlag; + for (const token of rest) { + if (token.includes(":=")) { + const sep = token.indexOf(":="); + args[token.slice(0, sep)] = looseParse(token.slice(sep + 2)); + continue; + } + if (token.startsWith("{")) { + const parsed = looseParse(token); + if (parsed !== null && typeof parsed === "object") { + Object.assign(args, parsed); + } + continue; + } + // First bare positional after the command: the tool (or prompt, or + // task id — the field is generic on purpose; `tool` is just its most + // common reading). + if (tool === null) tool = token; + } + + return { cmd, connection, tool, args, positionals }; +} + +/** Recursively: parse JSON-looking strings, sort object keys. */ +function normalize(value) { + const v = typeof value === "string" ? looseParse(value) : value; + if (v !== null && typeof v === "object") { + if (Array.isArray(v)) return v.map(normalize); + return Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, normalize(v[k])]), + ); + } + return v; +} + +/** Stable stringify: objects with sorted keys, so shape compares by value. */ +function canonical(value) { + return JSON.stringify(normalize(value)); +} + +/** + * Loose value equality: `2`, `"2"`, and a JSON string `"2"` all match, and + * objects compare deeply with key order ignored. Argument values arrive as + * strings from `--tool-arg a=2` and as numbers from `a:=2`; a case should + * not care which spelling the agent picked. + */ +export function valuesMatch(expected, actual) { + return canonical(expected) === canonical(actual); +} + +/** Concatenated output for one stream of a transcript record. */ +export function streamText(record, stream) { + return (record.events ?? []) + .filter((e) => e.stream === stream) + .map((e) => e.data) + .join(""); +} + +/** Read `a.b.c` out of a parsed JSON value. */ +function readPath(value, dotted) { + let cur = value; + for (const key of dotted.split(".")) { + if (cur === null || typeof cur !== "object") return undefined; + cur = cur[key]; + } + return cur; +} + +/** + * Match one transcript record against one matcher. + * + * @param {object} matcher See `validateBehaviorCase` for the shape. + * @param {object} record One shim transcript record. + * @returns {string | null} `null` on match, else the first mismatch reason. + */ +export function matchCall(matcher, record) { + const parsed = parseMcpdoArgv(record.argv ?? []); + if (parsed.cmd !== matcher.cmd) { + return `cmd is \`${parsed.cmd}\`, expected \`${matcher.cmd}\``; + } + if (matcher.connection !== undefined) { + // `connect` names its connection positionally (`connect test-stdio`), + // which the generic parse files under `tool` — for this command the + // target IS the connection being established, so match either spelling. + const conn = + parsed.connection ?? (matcher.cmd === "connect" ? parsed.tool : null); + // An absent connection is accepted: the hermetic env holds exactly one + // entry, so the implicit MRU can only be the right one. An EXPLICIT + // wrong connection is the bug this field exists to catch. + if (conn !== null && conn !== matcher.connection) { + return `connection is \`${conn}\`, expected \`${matcher.connection}\``; + } + } + if (matcher.tool !== undefined && parsed.tool !== matcher.tool) { + return `tool is \`${parsed.tool}\`, expected \`${matcher.tool}\``; + } + for (const [key, expected] of Object.entries(matcher.args ?? {})) { + if (!(key in parsed.args)) { + return `arg \`${key}\` missing (args: ${JSON.stringify(parsed.args)})`; + } + if (!valuesMatch(expected, parsed.args[key])) { + return `arg \`${key}\` is ${JSON.stringify(parsed.args[key])}, expected ${JSON.stringify(expected)}`; + } + } + // A failing invocation must not satisfy a matcher unless the case says so: + // `connect` that exited 1 did not connect. + const wantExit = matcher.exit ?? 0; + if (record.exit !== wantExit) { + return `exit is ${record.exit}, expected ${wantExit}`; + } + const stdout = streamText(record, "stdout"); + if (matcher.result !== undefined) { + let parsedOut; + try { + parsedOut = JSON.parse(stdout); + } catch { + // Distinct diagnostic on purpose: the call may have been right while + // the case asserted JSON against human text output. + return `\`result\` asserted but stdout is not JSON (use stdoutMatch for text output)`; + } + for (const [path, expected] of Object.entries(matcher.result)) { + const actual = readPath(parsedOut, path); + if (!valuesMatch(expected, actual)) { + return `result path \`${path}\` is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`; + } + } + } + if (matcher.stdoutMatch !== undefined) { + if (!new RegExp(matcher.stdoutMatch).test(stdout)) { + return `stdout does not match /${matcher.stdoutMatch}/`; + } + } + return null; +} + +/** + * Check the transcript contains the expected calls as an ordered + * subsequence — other calls in between are fine (exploring with + * `tools/list` first is correct behavior, not noise). + * + * @param {object[]} expectCalls + * @param {object[]} records + * @returns {{ ok: boolean, failures: string[] }} + */ +export function evalExpectCalls(expectCalls, records) { + const failures = []; + let cursor = 0; + for (const [i, matcher] of expectCalls.entries()) { + let matched = -1; + const nearMisses = []; + for (let j = cursor; j < records.length; j++) { + const reason = matchCall(matcher, records[j]); + if (reason === null) { + matched = j; + break; + } + // Same command, wrong details: that is the interesting diagnostic. + if (parseMcpdoArgv(records[j].argv ?? []).cmd === matcher.cmd) { + nearMisses.push(reason); + } + } + if (matched === -1) { + const detail = + nearMisses.length > 0 + ? ` (near miss: ${nearMisses[0]})` + : records.length === 0 + ? " (no mcpdo invocations recorded)" + : ""; + failures.push( + `expectCalls[${i}] \`${matcher.cmd}\` not satisfied${detail}`, + ); + } else { + cursor = matched + 1; + } + } + return { ok: failures.length === 0, failures }; +} + +const MATCHER_KEYS = new Set([ + "cmd", + "connection", + "tool", + "args", + "exit", + "result", + "stdoutMatch", +]); + +/** + * Validate one behavior case. Local to the mcpdo eval on purpose — the + * shared `skill-manifest.mjs` schema describes trigger cases for every + * skill, while `expectCalls` is this harness's private contract. + * + * Unknown matcher keys are errors, not ignored: a typoed `tooll` would + * otherwise silently assert nothing and report a hit. + * + * @param {object} c + * @param {number} i Case index, for error messages. + * @returns {string[]} Errors; empty when valid. + */ +export function validateBehaviorCase(c, i) { + const errors = []; + if (typeof c.prompt !== "string" || c.prompt.trim() === "") { + errors.push(`behavior case ${i}: \`prompt\` must be a non-empty string`); + } + if (!Array.isArray(c.expectCalls) || c.expectCalls.length === 0) { + errors.push( + `behavior case ${i}: \`expectCalls\` must be a non-empty array`, + ); + return errors; + } + c.expectCalls.forEach((m, j) => { + const at = `behavior case ${i} expectCalls[${j}]`; + if (m === null || typeof m !== "object" || Array.isArray(m)) { + errors.push(`${at}: must be an object`); + return; + } + if (typeof m.cmd !== "string" || m.cmd.trim() === "") { + errors.push(`${at}: \`cmd\` is required`); + } + for (const key of Object.keys(m)) { + if (!MATCHER_KEYS.has(key)) { + errors.push(`${at}: unknown key \`${key}\``); + } + } + for (const key of ["connection", "tool", "stdoutMatch"]) { + if (m[key] !== undefined && typeof m[key] !== "string") { + errors.push(`${at}: \`${key}\` must be a string`); + } + } + for (const key of ["args", "result"]) { + if ( + m[key] !== undefined && + (m[key] === null || typeof m[key] !== "object" || Array.isArray(m[key])) + ) { + errors.push(`${at}: \`${key}\` must be an object`); + } + } + if (m.exit !== undefined && !Number.isInteger(m.exit)) { + errors.push(`${at}: \`exit\` must be an integer`); + } + if (m.stdoutMatch !== undefined && typeof m.stdoutMatch === "string") { + try { + new RegExp(m.stdoutMatch); + } catch { + errors.push(`${at}: \`stdoutMatch\` is not a valid regex`); + } + } + }); + return errors; +} diff --git a/scripts/lib/mcpdo-eval-matchers.test.mjs b/scripts/lib/mcpdo-eval-matchers.test.mjs new file mode 100644 index 0000000000..d1a87e7b02 --- /dev/null +++ b/scripts/lib/mcpdo-eval-matchers.test.mjs @@ -0,0 +1,283 @@ +// Tests for the behavior-eval matcher library: every spelling mcpdo accepts +// for the same call must normalize to the same parse, and a matcher must +// fail for the reasons a case exists to catch (wrong tool, wrong argument, +// wrong server, nonzero exit) with a reason a human can act on. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseMcpdoArgv, + valuesMatch, + matchCall, + evalExpectCalls, + streamText, + validateBehaviorCase, +} from "./mcpdo-eval-matchers.mjs"; + +const record = (argv, { exit = 0, stdout = "", stderr = "" } = {}) => ({ + argv, + exit, + start: 1, + end: 2, + events: [ + ...(stdout ? [{ t: 1, stream: "stdout", data: stdout }] : []), + ...(stderr ? [{ t: 1, stream: "stderr", data: stderr }] : []), + ], +}); + +test("parseMcpdoArgv: every tools/call spelling normalizes the same", () => { + const spellings = [ + ["tools/call", "get_sum", "a:=2", "b:=3", "--connection", "test-stdio"], + ["tools/call", "--conn", "test-stdio", "get_sum", "a:=2", "b:=3"], + ["@test-stdio", "tools/call", "get_sum", '{"a":2,"b":3}'], + [ + "tools/call", + "--tool-name", + "get_sum", + "--tool-arg", + "a=2", + "b=3", + "--connection", + "test-stdio", + ], + [ + "tools/call", + "get_sum", + "--tool-args-json", + '{"a":2,"b":3}', + "--connection", + "test-stdio", + ], + ]; + for (const argv of spellings) { + const p = parseMcpdoArgv(argv); + assert.equal(p.cmd, "tools/call", argv.join(" ")); + assert.equal(p.connection, "test-stdio", argv.join(" ")); + assert.equal(p.tool, "get_sum", argv.join(" ")); + assert.ok(valuesMatch(2, p.args.a), argv.join(" ")); + assert.ok(valuesMatch(3, p.args.b), argv.join(" ")); + } +}); + +test("parseMcpdoArgv: global flags do not become positionals", () => { + const p = parseMcpdoArgv([ + "--format", + "json", + "--plain", + "tools/list", + "--connection", + "x", + ]); + assert.equal(p.cmd, "tools/list"); + assert.equal(p.connection, "x"); + assert.equal(p.tool, null); +}); + +test("parseMcpdoArgv: daemon subcommand folds into cmd", () => { + assert.equal(parseMcpdoArgv(["daemon", "stop"]).cmd, "daemon stop"); + assert.equal(parseMcpdoArgv(["daemon", "status"]).cmd, "daemon status"); +}); + +test("parseMcpdoArgv: boolean flag does not swallow the tool name", () => { + const p = parseMcpdoArgv(["tools/call", "--task", "slow_tool", "n:=1"]); + assert.equal(p.tool, "slow_tool"); + assert.ok(valuesMatch(1, p.args.n)); +}); + +test("valuesMatch: canonical across strings, numbers, and key order", () => { + assert.ok(valuesMatch(2, "2")); + assert.ok(valuesMatch("hello", "hello")); + assert.ok(valuesMatch({ b: 1, a: "2" }, { a: 2, b: 1 })); + assert.ok(!valuesMatch(2, 3)); + assert.ok(!valuesMatch("2", "2x")); +}); + +test("matchCall: full match on the add case", () => { + const r = record( + ["tools/call", "get_sum", "a:=2", "b:=3", "--connection", "test-stdio"], + { stdout: '{"result":5}' }, + ); + assert.equal( + matchCall( + { + cmd: "tools/call", + connection: "test-stdio", + tool: "get_sum", + args: { a: 2, b: 3 }, + result: { result: 5 }, + }, + r, + ), + null, + ); +}); + +test("matchCall: implicit MRU connection is accepted, explicit wrong one is not", () => { + const m = { cmd: "tools/list", connection: "test-stdio" }; + assert.equal(matchCall(m, record(["tools/list"])), null); + const wrong = matchCall(m, record(["tools/list", "--connection", "other"])); + assert.match(wrong, /connection is `other`/); +}); + +test("matchCall: connect names its connection positionally", () => { + const m = { cmd: "connect", connection: "test-stdio" }; + assert.equal(matchCall(m, record(["connect", "test-stdio"])), null); + assert.equal(matchCall(m, record(["connect", "@test-stdio"])), null); + assert.equal( + matchCall(m, record(["connect", "--connection", "test-stdio"])), + null, + ); + assert.match( + matchCall(m, record(["connect", "other-entry"])), + /connection is `other-entry`/, + ); +}); + +test("matchCall: wrong tool, wrong arg, missing arg, nonzero exit", () => { + const base = { + cmd: "tools/call", + tool: "get_sum", + args: { a: 2, b: 3 }, + }; + assert.match( + matchCall(base, record(["tools/call", "echo", "a:=2", "b:=3"])), + /tool is `echo`/, + ); + assert.match( + matchCall(base, record(["tools/call", "get_sum", "a:=2", "b:=4"])), + /arg `b` is 4/, + ); + assert.match( + matchCall(base, record(["tools/call", "get_sum", "a:=2"])), + /arg `b` missing/, + ); + assert.match( + matchCall( + base, + record(["tools/call", "get_sum", "a:=2", "b:=3"], { exit: 1 }), + ), + /exit is 1/, + ); +}); + +test("matchCall: result against text output is a distinct diagnostic", () => { + const r = record(["tools/call", "get_sum", "a:=2", "b:=3"], { + stdout: "result: 5\n", + }); + assert.match( + matchCall({ cmd: "tools/call", result: { result: 5 } }, r), + /stdout is not JSON/, + ); + assert.equal(matchCall({ cmd: "tools/call", stdoutMatch: "5" }, r), null); +}); + +test("matchCall: result reads dotted paths", () => { + const r = record(["tools/list"], { + stdout: '{"tools":[{"name":"get_sum"}]}', + }); + assert.equal( + matchCall({ cmd: "tools/list", result: { "tools.0.name": "get_sum" } }, r), + null, + ); +}); + +test("evalExpectCalls: ordered subsequence with unrelated calls between", () => { + const records = [ + record(["daemon", "status"]), + record(["connect", "test-stdio"]), + record(["tools/list"]), + record(["tools/call", "get_sum", "a:=2", "b:=3"]), + ]; + const { ok } = evalExpectCalls( + [ + { cmd: "connect", connection: "test-stdio" }, + { cmd: "tools/call", tool: "get_sum", args: { a: 2, b: 3 } }, + ], + records, + ); + assert.ok(ok); +}); + +test("evalExpectCalls: order violations and misses carry diagnostics", () => { + const records = [ + record(["tools/call", "get_sum", "a:=2", "b:=4"]), + record(["connect", "test-stdio"]), + ]; + const out = evalExpectCalls( + [ + { cmd: "connect", connection: "test-stdio" }, + { cmd: "tools/call", args: { b: 3 } }, + ], + records, + ); + assert.ok(!out.ok); + // connect matched (index 1), so the tools/call must come after it — the + // earlier wrong call does not count and there is no later one. + assert.equal(out.failures.length, 1); + assert.match(out.failures[0], /expectCalls\[1\]/); +}); + +test("evalExpectCalls: empty transcript says so", () => { + const out = evalExpectCalls([{ cmd: "connect" }], []); + assert.match(out.failures[0], /no mcpdo invocations recorded/); +}); + +test("streamText concatenates one stream in order", () => { + const r = { + events: [ + { t: 1, stream: "stdout", data: "a" }, + { t: 2, stream: "stderr", data: "X" }, + { t: 3, stream: "stdout", data: "b" }, + ], + }; + assert.equal(streamText(r, "stdout"), "ab"); + assert.equal(streamText(r, "stderr"), "X"); +}); + +test("validateBehaviorCase: accepts the real shape", () => { + assert.deepEqual( + validateBehaviorCase( + { + kind: "behavior", + prompt: "Add 2 and 3", + expectCalls: [ + { + cmd: "tools/call", + connection: "test-stdio", + tool: "get_sum", + args: { a: 2, b: 3 }, + stdoutMatch: "5", + }, + ], + }, + 0, + ), + [], + ); +}); + +test("validateBehaviorCase: catches typos, bad types, bad regex", () => { + const errs = validateBehaviorCase( + { + prompt: "", + expectCalls: [ + { cmd: "", tooll: "x" }, + { cmd: "ok", args: [], exit: "0", stdoutMatch: "(" }, + "nope", + ], + }, + 3, + ); + assert.ok(errs.some((e) => /`prompt`/.test(e))); + assert.ok(errs.some((e) => /`cmd` is required/.test(e))); + assert.ok(errs.some((e) => /unknown key `tooll`/.test(e))); + assert.ok(errs.some((e) => /`args` must be an object/.test(e))); + assert.ok(errs.some((e) => /`exit` must be an integer/.test(e))); + assert.ok(errs.some((e) => /not a valid regex/.test(e))); + assert.ok(errs.some((e) => /must be an object/.test(e))); +}); + +test("validateBehaviorCase: empty expectCalls is an error", () => { + const errs = validateBehaviorCase({ prompt: "p", expectCalls: [] }, 0); + assert.ok(errs.some((e) => /non-empty array/.test(e))); +}); diff --git a/scripts/lib/mcpdo-eval-shim.mjs b/scripts/lib/mcpdo-eval-shim.mjs new file mode 100644 index 0000000000..ac204de311 --- /dev/null +++ b/scripts/lib/mcpdo-eval-shim.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Transparent recording wrapper around the real mcpdo, for behavior evals. +// +// The behavior eval (`skills:eval:mcpdo`) needs to know what an agent's +// shell actually asked mcpdo to do and what mcpdo answered — WITHOUT parsing +// shell strings out of the agent's event stream (quoting rules, chained +// commands and subshells make that a reimplementation of `sh`). So the +// harness puts a `mcpdo` shim first on PATH; the shim execs this file, which +// spawns the REAL CLI and tees all three stdio streams through untouched +// while recording a timestamped transcript. +// +// One JSON line per invocation, appended to `$MCPDO_EVAL_LOG`: +// +// { argv, exit, start, end, events: [{ t, stream, data }] } +// +// `events` interleaves stdin/stdout/stderr in arrival order, so an +// interactive invocation (elicitation prompts answered over stdin, two-phase +// OAuth output on one blocked pipe) is captured as faithfully as an atomic +// one — for the simple case the transcript degenerates to a single stdout +// event. Output is recorded VERBATIM: the shim never injects `--format json` +// or any other flag, because which format the agent asked for is part of +// what is being measured. +// +// The record is written on child exit with a single appendFileSync — an +// O_APPEND write of one line, atomic enough for concurrent invocations +// within a sample (POSIX; the eval is POSIX-only, stated where it runs). + +import { spawn } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Run the real CLI, teeing stdio and recording the transcript. + * + * Injectable so tests can drive it with a fixture child and in-memory + * streams; the module main wires the real process. + * + * @param {object} opts + * @param {string} opts.realBin Path to the real mcp-bin.js. + * @param {string[]} opts.argv What the agent passed after `mcpdo`. + * @param {string} opts.logPath Transcript destination (NDJSON, appended). + * @param {NodeJS.ReadableStream} opts.stdin + * @param {NodeJS.WritableStream} opts.stdout + * @param {NodeJS.WritableStream} opts.stderr + * @param {typeof spawn} [opts.spawnFn] + * @param {typeof appendFileSync} [opts.appendFn] + * @returns {Promise} The child's exit code. + */ +export function runShim({ + realBin, + argv, + logPath, + stdin, + stdout, + stderr, + spawnFn = spawn, + appendFn = appendFileSync, +}) { + return new Promise((resolve, reject) => { + const record = { + argv, + exit: null, + start: Date.now(), + end: null, + events: [], + }; + const child = spawnFn(process.execPath, [realBin, ...argv], { + stdio: ["pipe", "pipe", "pipe"], + }); + const tap = (stream) => (chunk) => { + record.events.push({ + t: Date.now(), + stream, + data: chunk.toString("utf8"), + }); + }; + stdin.on("data", (chunk) => { + tap("stdin")(chunk); + child.stdin.write(chunk); + }); + stdin.on("end", () => child.stdin.end()); + // The child may exit without reading piped stdin; that EPIPE is its + // business, not a shim failure. + child.stdin.on("error", () => {}); + child.stdout.on("data", (chunk) => { + tap("stdout")(chunk); + stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => { + tap("stderr")(chunk); + stderr.write(chunk); + }); + child.on("error", reject); + child.on("close", (code) => { + record.exit = code ?? 1; + record.end = Date.now(); + appendFn(logPath, JSON.stringify(record) + "\n"); + resolve(record.exit); + }); + }); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const realBin = process.env.MCPDO_EVAL_REAL; + const logPath = process.env.MCPDO_EVAL_LOG; + if (!realBin || !logPath) { + process.stderr.write( + "mcpdo-eval-shim: MCPDO_EVAL_REAL and MCPDO_EVAL_LOG must be set\n", + ); + process.exit(2); + } + runShim({ + realBin, + argv: process.argv.slice(2), + logPath, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }).then( + (code) => process.exit(code), + (err) => { + process.stderr.write(`mcpdo-eval-shim: ${err?.message ?? err}\n`); + process.exit(2); + }, + ); +} diff --git a/scripts/lib/mcpdo-eval-shim.test.mjs b/scripts/lib/mcpdo-eval-shim.test.mjs new file mode 100644 index 0000000000..d7ff66cea0 --- /dev/null +++ b/scripts/lib/mcpdo-eval-shim.test.mjs @@ -0,0 +1,90 @@ +// The shim's whole job is fidelity: same argv to the real binary, same bytes +// on the same streams, same exit code — with a transcript on the side. So +// the test drives it end-to-end against a fixture "real CLI" and asserts on +// all four at once. A unit test of the internals would pass while the tee +// dropped a stream (Copilot). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SHIM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "mcpdo-eval-shim.mjs", +); + +// Echoes argv on stdout, a marker + upper-cased stdin on stderr, exits 3. +const FIXTURE = ` +let input = ""; +process.stdin.on("data", (c) => (input += c)); +process.stdin.on("end", () => { + process.stdout.write("argv:" + process.argv.slice(2).join(",") + "\\n"); + process.stderr.write("err:" + input.toUpperCase()); + process.exit(3); +}); +`; + +function runShimProcess(args, { stdinText = "", env = {} } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [SHIM, ...args], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += c)); + child.stderr.on("data", (c) => (stderr += c)); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(stdinText); + }); +} + +test("shim tees stdio verbatim, mirrors exit, and records the transcript", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-shim-test-")); + try { + const realBin = path.join(dir, "fixture.mjs"); + const logPath = path.join(dir, "log.ndjson"); + writeFileSync(realBin, FIXTURE); + + const out = await runShimProcess(["tools/call", "get_sum", "a:=2"], { + stdinText: "hi", + env: { MCPDO_EVAL_REAL: realBin, MCPDO_EVAL_LOG: logPath }, + }); + assert.equal(out.code, 3); + assert.equal(out.stdout, "argv:tools/call,get_sum,a:=2\n"); + assert.equal(out.stderr, "err:HI"); + + const records = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + assert.equal(records.length, 1); + const r = records[0]; + assert.deepEqual(r.argv, ["tools/call", "get_sum", "a:=2"]); + assert.equal(r.exit, 3); + assert.ok(r.start <= r.end); + const byStream = (s) => + r.events + .filter((e) => e.stream === s) + .map((e) => e.data) + .join(""); + assert.equal(byStream("stdin"), "hi"); + assert.equal(byStream("stdout"), "argv:tools/call,get_sum,a:=2\n"); + assert.equal(byStream("stderr"), "err:HI"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("shim refuses to run without its env contract", async () => { + const out = await runShimProcess(["anything"], { + env: { MCPDO_EVAL_REAL: "", MCPDO_EVAL_LOG: "" }, + }); + assert.equal(out.code, 2); + assert.match(out.stderr, /MCPDO_EVAL_REAL and MCPDO_EVAL_LOG/); +}); diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index 7dc1c279c8..399644a763 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -37,13 +37,36 @@ // samples. Run it before and after editing `skills/mcpdo/SKILL.md`, and // compare the same cases. // +// BEHAVIOR cases (`"kind": "behavior"` in evals.json) go one layer deeper +// than trigger cases: did the agent run the RIGHT mcpdo commands? Each +// sample gets a hermetic environment — a private daemon binding, a throwaway +// storage dir, and a catalog holding exactly one entry (`test-stdio`, this +// repo's stdio test server) — and a recording `mcpdo` shim first on PATH +// that tees stdio through the real build while appending a transcript per +// invocation (lib/mcpdo-eval-shim.mjs). Scoring matches the transcript +// against the case's `expectCalls` (lib/mcpdo-eval-matchers.mjs): an ordered +// subsequence of structured matchers over parsed argv, exit codes, and +// captured output — never over shell strings from the agent's event stream. +// The shell containment is command-scoped approval, probed live on both +// CLIs: Claude `--allowedTools "Bash(mcpdo *)"`, Copilot `--allow-tool +// 'shell(mcpdo:*)'`; anything else effectful is auto-denied fast in headless +// mode and the run continues. (Copilot's `--available-tools` is deliberately +// NOT used here: its availability names differ from its pattern names, and +// filtering the shell tool out entirely makes the model fabricate command +// output — measured, not hypothesized.) Behavior cases are POSIX-only: the +// shim is installed as a `#!/bin/sh` wrapper. +// // Usage: // npm run skills:eval:mcpdo // RUNS=5 THRESHOLD=0.8 npm run skills:eval:mcpdo // AGENT=copilot npm run skills:eval:mcpdo +// BEHAVIOR_RUNS=4 BEHAVIOR_THRESHOLD=0.75 npm run skills:eval:mcpdo +import { spawnSync } from "node:child_process"; import { + chmodSync, cpSync, + existsSync, mkdirSync, readFileSync, rmSync, @@ -55,6 +78,10 @@ import { randomBytes } from "node:crypto"; import { fileURLToPath } from "node:url"; import { AGENTS, formatReport, runPrompt } from "./skill-eval.mjs"; import { parseSkill, validateEvalCases } from "./lib/skill-manifest.mjs"; +import { + evalExpectCalls, + validateBehaviorCase, +} from "./lib/mcpdo-eval-matchers.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SKILL_DIR = path.join(ROOT, "skills", "mcpdo"); @@ -74,10 +101,35 @@ const EVALS_FILE = path.join( ); const SKILL_NAME = "mcpdo"; +// Behavior-eval fixtures: the real CLI build the shim wraps, the shim +// itself, and the stdio test server the private catalog points at. Builds, +// not sources — the eval measures what a user would run. +const REAL_BIN = path.join( + ROOT, + "clients", + "daemon-cli", + "build", + "mcp-bin.js", +); +const SHIM_SRC = path.join(ROOT, "scripts", "lib", "mcpdo-eval-shim.mjs"); +const TEST_SERVER_BIN = path.join( + ROOT, + "test-servers", + "build", + "test-server-stdio.js", +); + const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); const RUNS = Number(process.env.RUNS ?? 3); const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4); const AGENT = process.env.AGENT ?? "claude"; +// Behavior knobs are separate from the trigger ones: a multi-turn agentic +// run costs an order of magnitude more than a one-turn trigger sample, and +// its hit rate is honestly lower — 0.5 strict to start, tightened as the +// skill improves rather than loosened to pass. +const BEHAVIOR_RUNS = Number(process.env.BEHAVIOR_RUNS ?? RUNS); +const BEHAVIOR_THRESHOLD = Number(process.env.BEHAVIOR_THRESHOLD ?? 0.5); +const BEHAVIOR_TURNS = Number(process.env.BEHAVIOR_TURNS ?? 14); /** * Build the sandbox project one sample set runs in. @@ -107,7 +159,16 @@ export function makeSandbox() { return dir; } -/** Load and validate the committed cases. */ +/** + * Load and validate the committed cases, partitioned by kind. + * + * Trigger cases go through the shared `validateEvalCases` schema; behavior + * cases through this harness's own `validateBehaviorCase` — `expectCalls` is + * a private contract of this runner, and the shared schema should not grow + * fields only one skill's eval understands. + * + * @returns {{ trigger: object[], behavior: object[] }} + */ export function loadCases() { const skill = parseSkill( SKILL_NAME, @@ -121,13 +182,209 @@ export function loadCases() { "skills/mcpdo is not model-invoked; a trigger eval of it measures nothing", ); } - const evalsFile = EVALS_FILE; - const cases = JSON.parse(readFileSync(evalsFile, "utf8")); - const invalid = validateEvalCases(SKILL_NAME, cases, new Set([SKILL_NAME])); - if (invalid.length > 0) { - throw new Error(`skills/mcpdo/evals/evals.json: ${invalid.join("; ")}`); + const all = JSON.parse(readFileSync(EVALS_FILE, "utf8")); + if (!Array.isArray(all)) { + throw new Error("clients/daemon-cli/evals/evals.json must be an array"); + } + // `kind` is an explicit discriminator, required on every case: a defaulted + // kind would let a typo ("behaviour") silently demote a behavior case to a + // trigger case and fail with an unrelated schema error. + const KINDS = new Set(["trigger", "behavior"]); + const kindErrors = all.flatMap((c, i) => + KINDS.has(c?.kind) + ? [] + : [ + `case ${i}: \`kind\` must be one of ${[...KINDS].join(", ")} (got ${JSON.stringify(c?.kind)})`, + ], + ); + if (kindErrors.length > 0) { + throw new Error( + `clients/daemon-cli/evals/evals.json: ${kindErrors.join("; ")}`, + ); + } + const trigger = all.filter((c) => c.kind === "trigger"); + const behavior = all.filter((c) => c.kind === "behavior"); + const errors = [ + ...validateEvalCases(SKILL_NAME, trigger, new Set([SKILL_NAME])), + ...behavior.flatMap((c, i) => validateBehaviorCase(c, i)), + ]; + if (errors.length > 0) { + throw new Error( + `clients/daemon-cli/evals/evals.json: ${errors.join("; ")}`, + ); + } + return { trigger, behavior }; +} + +/** + * Agent arguments for a BEHAVIOR run: the trigger policy plus shell, scoped + * to mcpdo by command-level approval. Both syntaxes were probed live (see + * the header): unapproved effectful commands fail fast and the run + * continues, so containment costs turns, not hangs. + * + * Passed to `runPrompt` as `agentArgsFn` — a replacement, because the + * trigger policy's `--deny-tool shell` / `--disallowedTools Bash` cannot be + * retracted by appending. + * + * @param {string} agent + * @param {number} maxTurns + * @returns {string[]} + */ +export function behaviorAgentArgs(agent, maxTurns) { + if (agent === "copilot") { + return [ + "--output-format", + "json", + // No `--available-tools`: its availability names differ from the + // approval-pattern names (the shell tool is `bash` in events but + // `shell(...)` in patterns), and naming it wrong silently removes the + // tool — after which the model FABRICATES command output. Approval + // scoping alone contains the run: everything unapproved is auto-denied + // in headless mode. + "--allow-tool", + "view,glob,grep,skill", + "--allow-tool", + "shell(mcpdo:*)", + "--deny-tool", + "write", + "--deny-tool", + "url", + "--disable-builtin-mcps", + "--disallow-temp-dir", + "--no-ask-user", + "--no-auto-update", + ]; + } + if (agent !== "claude") throw new Error(`unknown agent \`${agent}\``); + return [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--max-turns", + String(maxTurns), + "--tools", + "Read,Glob,Grep,Skill,Bash", + "--allowedTools", + "Read,Glob,Grep,Skill,Bash(mcpdo *)", + "--disallowedTools", + "Write,Edit,NotebookEdit,Task,Agent,SlashCommand,WebFetch,WebSearch,KillShell", + "--strict-mcp-config", + ]; +} + +/** + * Build one behavior sample's hermetic mcpdo world, next to (not inside) its + * sandbox so the agent's cwd stays clean. + * + * Private daemon binding (own dir + minted token — the same isolation + * `mcpdo private` gives a shell), throwaway storage, a catalog holding + * exactly `test-stdio`, and a bin dir whose `mcpdo` is the recording shim. + * One entry on purpose: with a single catalog entry, an implicit-MRU call + * can only mean the right server, which is what lets the `connection` + * matcher accept the flag's absence. + * + * No `MCP_ALLOW_DEFAULT_CONNECTION`: agents run non-TTY, and the explicit + * connect-or-name path is the realistic one being measured. + * + * @param {string} sandbox The sample's sandbox dir (from `makeSandbox`). + * @returns {{ env: Record, logPath: string, teardown: () => + * void }} + */ +export function makeBehaviorEnv(sandbox) { + const envDir = `${sandbox}-env`; + const daemonDir = path.join(envDir, "daemon"); + const storageDir = path.join(envDir, "storage"); + const binDir = path.join(envDir, "bin"); + const logPath = path.join(envDir, "mcpdo-transcript.ndjson"); + const catalogPath = path.join(envDir, "catalog.json"); + mkdirSync(daemonDir, { recursive: true, mode: 0o700 }); + mkdirSync(storageDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + catalogPath, + JSON.stringify( + { + mcpServers: { + "test-stdio": { + type: "stdio", + command: process.execPath, + args: [TEST_SERVER_BIN], + }, + }, + }, + null, + 2, + ), + ); + const shimBin = path.join(binDir, "mcpdo"); + writeFileSync( + shimBin, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(SHIM_SRC)} "$@"\n`, + ); + chmodSync(shimBin, 0o755); + const env = { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + MCP_INSPECTOR_DAEMON_DIR: daemonDir, + MCP_INSPECTOR_DAEMON_TOKEN: randomBytes(32).toString("base64url"), + MCP_STORAGE_DIR: storageDir, + MCP_CATALOG_PATH: catalogPath, + MCPDO_EVAL_REAL: REAL_BIN, + MCPDO_EVAL_LOG: logPath, + }; + const teardown = () => { + // Direct spawn of the real build, not the shim: teardown must not + // appear in the transcript, and must work even if the shim is broken. + spawnSync(process.execPath, [REAL_BIN, "daemon", "stop"], { + env: { ...process.env, ...env }, + timeout: 15000, + stdio: "ignore", + }); + rmSync(envDir, { recursive: true, force: true }); + }; + return { env, logPath, teardown }; +} + +/** Parse the shim transcript; tolerate a torn final line, never silent-drop. */ +export function readTranscript(logPath) { + if (!existsSync(logPath)) return []; + return readFileSync(logPath, "utf8") + .split("\n") + .filter((l) => l.trim() !== "") + .flatMap((l) => { + try { + return [JSON.parse(l)]; + } catch { + return []; + } + }); +} + +/** + * Run one behavior sample: fresh sandbox + hermetic env, one agent session, + * transcript scored against the case's `expectCalls`. + * + * @param {object} c A behavior case. + * @returns {Promise<{ hit: boolean, failures: string[], calls: number }>} + */ +async function runBehaviorSample(c) { + const sandbox = makeSandbox(); + const { env, logPath, teardown } = makeBehaviorEnv(sandbox); + try { + await runPrompt(c.prompt, { + cwd: sandbox, + agent: AGENT, + maxTurns: BEHAVIOR_TURNS, + env, + agentArgsFn: behaviorAgentArgs, + }); + const records = readTranscript(logPath); + const { ok, failures } = evalExpectCalls(c.expectCalls, records); + return { hit: ok, failures, calls: records.length }; + } finally { + teardown(); + rmSync(sandbox, { recursive: true, force: true }); } - return cases; } async function pool(items, n, fn) { @@ -151,18 +408,37 @@ async function main() { ); process.exit(1); } - if (!Number.isFinite(THRESHOLD) || THRESHOLD < 0 || THRESHOLD > 1) { - console.error( - `skills:eval:mcpdo — THRESHOLD must be a number in [0, 1] (got ${process.env.THRESHOLD}).`, - ); - process.exit(1); + for (const [name, value] of [ + ["THRESHOLD", THRESHOLD], + ["BEHAVIOR_THRESHOLD", BEHAVIOR_THRESHOLD], + ]) { + if (!Number.isFinite(value) || value < 0 || value > 1) { + console.error( + `skills:eval:mcpdo — ${name} must be a number in [0, 1] (got ${process.env[name]}).`, + ); + process.exit(1); + } } - const cases = loadCases().map((c) => ({ ...c, from: SKILL_NAME })); + const { trigger, behavior } = loadCases(); + let failed = 0; + failed += await runTriggerSection( + trigger.map((c) => ({ ...c, from: SKILL_NAME })), + ); + failed += await runBehaviorSection(behavior); + process.exit(failed > 0 ? 1 : 0); +} + +/** + * Trigger section: did the skill fire? One shared read-only sandbox. + * + * @returns {Promise} Failed case count. + */ +async function runTriggerSection(cases) { + if (cases.length === 0) return 0; const sandbox = makeSandbox(); console.log( - `skills:eval:mcpdo — ${cases.length} cases x ${RUNS} runs, agent ${AGENT}, sandbox ${sandbox}`, + `skills:eval:mcpdo trigger — ${cases.length} cases x ${RUNS} runs, agent ${AGENT}, sandbox ${sandbox}`, ); - const samples = cases.flatMap((c) => Array.from({ length: RUNS }, () => c)); try { const results = await pool(samples, CONCURRENCY, async (c) => { @@ -189,12 +465,64 @@ async function main() { }, ); for (const line of lines) console.log(line); - process.exit(failed > 0 ? 1 : 0); + return failed; } finally { rmSync(sandbox, { recursive: true, force: true }); } } +/** + * Behavior section: did the agent run the right mcpdo commands? One hermetic + * world per SAMPLE — samples must not share MRU or connection state. + * + * @returns {Promise} Failed case count. + */ +async function runBehaviorSection(cases) { + if (cases.length === 0) return 0; + if (process.platform === "win32") { + console.log( + "skills:eval:mcpdo behavior — skipped: the recording shim is POSIX-only", + ); + return 0; + } + if (!existsSync(REAL_BIN) || !existsSync(TEST_SERVER_BIN)) { + console.error( + "skills:eval:mcpdo behavior — missing builds; run `npm run build` first" + + ` (need ${path.relative(ROOT, REAL_BIN)} and ${path.relative(ROOT, TEST_SERVER_BIN)})`, + ); + return 1; + } + console.log( + `skills:eval:mcpdo behavior — ${cases.length} cases x ${BEHAVIOR_RUNS} runs, agent ${AGENT}, budget ${BEHAVIOR_TURNS} turns`, + ); + const samples = cases.flatMap((c) => + Array.from({ length: BEHAVIOR_RUNS }, () => c), + ); + const results = await pool(samples, CONCURRENCY, async (c) => ({ + c, + ...(await runBehaviorSample(c)), + })); + let failed = 0; + for (const c of cases) { + const mine = results.filter((r) => r.c === c); + const hits = mine.filter((r) => r.hit).length; + const rate = hits / mine.length; + const pass = rate >= BEHAVIOR_THRESHOLD; + if (!pass) failed++; + console.log( + `${pass ? "PASS" : "FAIL"} behavior ${hits}/${mine.length} (need ${BEHAVIOR_THRESHOLD}) — ${c.prompt}`, + ); + for (const r of mine) { + if (!r.hit) { + console.log( + ` miss (${r.calls} mcpdo calls): ${r.failures.join("; ")}`, + ); + } + } + } + return failed; +} + if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 735643d77a..cf243b8bdd 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -674,6 +674,15 @@ export function runPrompt( maxTurns = 1, agent = "claude", killFn = killTree, + // Additive seams for the mcpdo BEHAVIOR eval (skill-eval-mcpdo.mjs): + // `env` merges over the inherited environment (the behavior eval puts a + // recording shim first on PATH and binds a private daemon), and + // `agentArgsFn` replaces the whole argument builder — replacement, not + // appending, because a policy that must allow shell cannot be reached by + // appending to one that denies it (`--deny-tool shell` has no inverse + // flag). Defaults preserve this file's read-only trigger policy exactly. + env = null, + agentArgsFn = agentArgs, } = {}, ) { return new Promise((resolve, reject) => { @@ -685,9 +694,10 @@ export function runPrompt( // process table. const { command, args, options } = cliSpawnArgs( agent, - agentArgs(agent, maxTurns), + agentArgsFn(agent, maxTurns), { cwd, + ...(env ? { env: { ...process.env, ...env } } : {}), stdio: ["pipe", "pipe", "inherit"], // Its own process group, so `killTree` can reach the native binary the // wrapper starts. Windows has no groups; `taskkill /T` covers it. From 800d51a7efa5e06a9025223a4058c6e0f856e7d2 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 14:05:22 -0700 Subject: [PATCH 41/69] eval(mcpdo): per-case server composition and transcript-phase matchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-2 enabling slice for the behavior eval — pure deterministic tooling, no new model-dependent cases yet. - Optional `server` field on behavior cases: {url} points the hermetic catalog entry at a running HTTP fixture; the composed (config-file) form is written to disk and served through a new eval-owned stdio launcher (loadConfig -> resolveConfig -> TestServerStdio) — the composable framework's own path, not an extension of test-server-stdio's purpose-built default entrypoint. The harness injects transport:{type:"stdio"}; specs claiming another transport are rejected. - `phases` matcher key: ordered cross-stream regex phases over one invocation's recorded stdin/stdout/stderr, via a global-timeline segment mapping (matches span chunk boundaries; ordering enforced across streams); distinct diagnostics for each failure mode. - validateServerSpec exported and wired into validateBehaviorCase so bad specs fail at case load, before any model run. - 11 new tests incl. a launcher integration test driving a raw ndjson JSON-RPC handshake (skips when test-servers/build is absent); suite 775 -> 786. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- scripts/lib/mcpdo-eval-matchers.mjs | 173 ++++++++++++++++ scripts/lib/mcpdo-eval-matchers.test.mjs | 142 +++++++++++++ scripts/lib/mcpdo-eval-server-launcher.mjs | 57 ++++++ scripts/skill-eval-mcpdo.mjs | 56 +++-- scripts/skill-eval-mcpdo.test.mjs | 225 +++++++++++++++++++++ 5 files changed, 638 insertions(+), 15 deletions(-) create mode 100644 scripts/lib/mcpdo-eval-server-launcher.mjs create mode 100644 scripts/skill-eval-mcpdo.test.mjs diff --git a/scripts/lib/mcpdo-eval-matchers.mjs b/scripts/lib/mcpdo-eval-matchers.mjs index 397ea05e53..3fa5b410f8 100644 --- a/scripts/lib/mcpdo-eval-matchers.mjs +++ b/scripts/lib/mcpdo-eval-matchers.mjs @@ -188,6 +188,93 @@ function readPath(value, dotted) { return cur; } +/** + * Per-stream views of a transcript with a mapping back to GLOBAL event + * order. + * + * Two problems solved at once. A pipe does not preserve write boundaries, so + * a phase's pattern may span two recorded chunks — matching must run over + * each stream's concatenated text, not per event. But interactive ordering + * ("the stdin answer came after the stdout prompt") is BETWEEN streams, so + * every character also needs a position on the one shared timeline; segments + * carry that mapping. + * + * @param {object} record One shim transcript record. + * @returns {Map} + */ +export function buildTimeline(record) { + const streams = new Map(); + let global = 0; + for (const event of record.events ?? []) { + const data = String(event.data ?? ""); + let entry = streams.get(event.stream); + if (!entry) { + entry = { text: "", segments: [] }; + streams.set(event.stream, entry); + } + entry.segments.push({ + streamStart: entry.text.length, + globalStart: global, + len: data.length, + }); + entry.text += data; + global += data.length; + } + return streams; +} + +/** Global timeline position of a stream-local offset. */ +function globalPos(entry, streamOffset) { + for (const seg of entry.segments) { + if (streamOffset < seg.streamStart + seg.len) { + return seg.globalStart + (streamOffset - seg.streamStart); + } + } + return Number.MAX_SAFE_INTEGER; +} + +/** + * Match ordered phases against one invocation's interleaved transcript. + * + * Each phase is `{ stream, match }`: a regex that must appear on that stream + * strictly AFTER (on the global timeline) where the previous phase matched. + * This is what turns the shim's event capture into assertions like "stdout + * showed the auth URL before exit" or "stdin answered only after the prompt + * appeared". + * + * @param {{ stream: string, match: string }[]} phases + * @param {object} record One shim transcript record. + * @returns {string | null} `null` on match, else the first failure reason. + */ +export function matchPhases(phases, record) { + const streams = buildTimeline(record); + let cursor = -1; + for (const [i, phase] of phases.entries()) { + const entry = streams.get(phase.stream); + if (!entry) { + return `phase ${i} /${phase.match}/: no ${phase.stream} data recorded`; + } + const re = new RegExp(phase.match, "g"); + let found = -1; + for (const m of entry.text.matchAll(re)) { + const end = globalPos(entry, m.index + Math.max(m[0].length, 1) - 1); + if (end > cursor) { + found = end; + break; + } + } + if (found === -1) { + const anywhere = new RegExp(phase.match).test(entry.text); + return anywhere + ? `phase ${i} /${phase.match}/ matched ${phase.stream} only BEFORE phase ${i - 1}` + : `phase ${i} /${phase.match}/ not found on ${phase.stream}`; + } + cursor = found; + } + return null; +} + /** * Match one transcript record against one matcher. * @@ -252,6 +339,10 @@ export function matchCall(matcher, record) { return `stdout does not match /${matcher.stdoutMatch}/`; } } + if (matcher.phases !== undefined) { + const reason = matchPhases(matcher.phases, record); + if (reason !== null) return reason; + } return null; } @@ -306,8 +397,11 @@ const MATCHER_KEYS = new Set([ "exit", "result", "stdoutMatch", + "phases", ]); +const PHASE_STREAMS = new Set(["stdin", "stdout", "stderr"]); + /** * Validate one behavior case. Local to the mcpdo eval on purpose — the * shared `skill-manifest.mjs` schema describes trigger cases for every @@ -368,6 +462,85 @@ export function validateBehaviorCase(c, i) { errors.push(`${at}: \`stdoutMatch\` is not a valid regex`); } } + if (m.phases !== undefined) { + if (!Array.isArray(m.phases) || m.phases.length === 0) { + errors.push(`${at}: \`phases\` must be a non-empty array`); + } else { + m.phases.forEach((p, k) => { + if (p === null || typeof p !== "object" || Array.isArray(p)) { + errors.push(`${at}: phases[${k}] must be an object`); + return; + } + for (const key of Object.keys(p)) { + if (key !== "stream" && key !== "match") { + errors.push(`${at}: phases[${k}] unknown key \`${key}\``); + } + } + if (!PHASE_STREAMS.has(p.stream)) { + errors.push( + `${at}: phases[${k}].stream must be one of ${[...PHASE_STREAMS].join(", ")}`, + ); + } + if (typeof p.match !== "string") { + errors.push(`${at}: phases[${k}].match must be a string`); + } else { + try { + new RegExp(p.match); + } catch { + errors.push(`${at}: phases[${k}].match is not a valid regex`); + } + } + }); + } + } }); + errors.push(...validateServerSpec(c.server, i)); return errors; } + +/** + * Validate a behavior case's optional `server` field: either + * `{ "url": "" }` for a server the harness does not spawn, + * or the test-servers declarative config-file shape (serverInfo + preset + * refs), which the harness writes to disk and serves through the eval's + * stdio launcher. Only the discriminating structure is checked here — preset + * names and capability switches are the framework's contract, validated by + * `resolveConfig` when the server starts. + * + * @param {object | undefined} server + * @param {number} i Case index, for error messages. + * @returns {string[]} + */ +export function validateServerSpec(server, i) { + if (server === undefined) return []; + const at = `behavior case ${i} \`server\``; + if (server === null || typeof server !== "object" || Array.isArray(server)) { + return [`${at}: must be an object`]; + } + if ("url" in server) { + const errors = []; + if (typeof server.url !== "string" || !/^https?:\/\//.test(server.url)) { + errors.push(`${at}: \`url\` must be an http(s) URL`); + } + for (const key of Object.keys(server)) { + if (key !== "url") { + errors.push(`${at}: \`url\` form takes no other keys (got \`${key}\`)`); + } + } + return errors; + } + if ( + typeof server.serverInfo?.name !== "string" || + typeof server.serverInfo?.version !== "string" + ) { + return [ + `${at}: composed form needs \`serverInfo\` with \`name\` and \`version\` (or use the \`url\` form)`, + ]; + } + if (server.transport !== undefined && server.transport?.type !== "stdio") { + return [ + `${at}: composed servers are spawned over stdio; omit \`transport\` (for an HTTP fixture, use the \`url\` form)`, + ]; + } + return []; +} diff --git a/scripts/lib/mcpdo-eval-matchers.test.mjs b/scripts/lib/mcpdo-eval-matchers.test.mjs index d1a87e7b02..a905e5825c 100644 --- a/scripts/lib/mcpdo-eval-matchers.test.mjs +++ b/scripts/lib/mcpdo-eval-matchers.test.mjs @@ -9,9 +9,11 @@ import { parseMcpdoArgv, valuesMatch, matchCall, + matchPhases, evalExpectCalls, streamText, validateBehaviorCase, + validateServerSpec, } from "./mcpdo-eval-matchers.mjs"; const record = (argv, { exit = 0, stdout = "", stderr = "" } = {}) => ({ @@ -277,6 +279,146 @@ test("validateBehaviorCase: catches typos, bad types, bad regex", () => { assert.ok(errs.some((e) => /must be an object/.test(e))); }); +test("matchPhases: interleaved prompt/answer/result ordering", () => { + const r = { + argv: ["tools/call", "collect"], + exit: 0, + events: [ + { t: 1, stream: "stdout", data: "Enter your na" }, + { t: 2, stream: "stdout", data: "me: " }, // pattern spans chunks + { t: 3, stream: "stdin", data: "Ada\n" }, + { t: 4, stream: "stdout", data: '{"ok":true}\n' }, + ], + }; + assert.equal( + matchPhases( + [ + { stream: "stdout", match: "Enter your name" }, + { stream: "stdin", match: "Ada" }, + { stream: "stdout", match: '"ok"' }, + ], + r, + ), + null, + ); + // The answer cannot come before the prompt. + assert.match( + matchPhases( + [ + { stream: "stdin", match: "Ada" }, + { stream: "stdout", match: "Enter your name" }, + ], + r, + ), + /matched stdout only BEFORE/, + ); + assert.match( + matchPhases([{ stream: "stderr", match: "x" }], r), + /no stderr data recorded/, + ); + assert.match( + matchPhases([{ stream: "stdout", match: "missing" }], r), + /not found on stdout/, + ); +}); + +test("matchCall: phases participate in a full matcher", () => { + const r = record(["connect", "test-stdio"], { + stdout: "Visit https://idp.example/auth to continue\nConnection ready\n", + }); + assert.equal( + matchCall( + { + cmd: "connect", + phases: [ + { stream: "stdout", match: "https://idp\\.example/auth" }, + { stream: "stdout", match: "Connection ready" }, + ], + }, + r, + ), + null, + ); +}); + +test("validateBehaviorCase: phases schema", () => { + const errs = validateBehaviorCase( + { + prompt: "p", + expectCalls: [ + { + cmd: "connect", + phases: [ + { stream: "socket", match: "x" }, + { stream: "stdout", match: "(", extra: 1 }, + "nope", + ], + }, + { cmd: "ok", phases: [] }, + ], + }, + 0, + ); + assert.ok(errs.some((e) => /phases\[0\]\.stream must be one of/.test(e))); + assert.ok( + errs.some((e) => /phases\[1\]\.match is not a valid regex/.test(e)), + ); + assert.ok(errs.some((e) => /phases\[1\] unknown key `extra`/.test(e))); + assert.ok(errs.some((e) => /phases\[2\] must be an object/.test(e))); + assert.ok(errs.some((e) => /`phases` must be a non-empty array/.test(e))); +}); + +test("validateServerSpec: url form, composed form, and rejects", () => { + assert.deepEqual(validateServerSpec(undefined, 0), []); + assert.deepEqual( + validateServerSpec({ url: "http://127.0.0.1:3999/mcp" }, 0), + [], + ); + assert.deepEqual( + validateServerSpec( + { + serverInfo: { name: "composed", version: "1.0.0" }, + tools: [{ preset: "add" }], + }, + 0, + ), + [], + ); + assert.ok( + validateServerSpec({ url: "ftp://x" }, 0).some((e) => + /http\(s\) URL/.test(e), + ), + ); + assert.ok( + validateServerSpec({ url: "http://x", tools: [{ preset: "add" }] }, 0).some( + (e) => /no other keys/.test(e), + ), + ); + assert.ok( + validateServerSpec({ tools: [{ preset: "add" }] }, 0).some((e) => + /needs `serverInfo`/.test(e), + ), + ); + assert.ok( + validateServerSpec( + { + serverInfo: { name: "c", version: "1" }, + transport: { type: "streamable-http" }, + }, + 0, + ).some((e) => /omit `transport`/.test(e)), + ); + assert.ok(validateServerSpec([], 0).some((e) => /must be an object/.test(e))); +}); + +test("validateBehaviorCase: server field is validated through the case", () => { + const errs = validateBehaviorCase( + { prompt: "p", expectCalls: [{ cmd: "connect" }], server: { url: "nope" } }, + 2, + ); + assert.ok(errs.some((e) => /behavior case 2 `server`/.test(e))); +}); + test("validateBehaviorCase: empty expectCalls is an error", () => { const errs = validateBehaviorCase({ prompt: "p", expectCalls: [] }, 0); assert.ok(errs.some((e) => /non-empty array/.test(e))); diff --git a/scripts/lib/mcpdo-eval-server-launcher.mjs b/scripts/lib/mcpdo-eval-server-launcher.mjs new file mode 100644 index 0000000000..596c033252 --- /dev/null +++ b/scripts/lib/mcpdo-eval-server-launcher.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Stdio entrypoint for a COMPOSED test server, owned by the mcpdo behavior +// eval (`skills:eval:mcpdo`). +// +// A behavior case may carry a `server` field — the test-servers declarative +// config-file shape (serverInfo + preset refs + capability switches; see +// test-servers/src/load-config.ts). The harness writes it to disk and points +// the sample's catalog entry at this launcher, so each case talks to exactly +// the server it needs: an eliciting tool, task tools, subscriptions, +// whatever the preset registry can compose. +// +// Deliberately NOT an extension of `test-server-stdio.js`: that entrypoint +// is a purpose-built default composition and stays that way. This launcher +// is the composition path the framework already exposes — `loadConfig` → +// `resolveConfig` → `TestServerStdio` (whose constructor takes any +// ServerConfig; only its standalone main hard-wires the default). Built +// output is imported, same as the eval's use of the default server: the +// eval measures what a user would run, and `scripts/` cannot import the +// workspace package by name anyway. +// +// Usage: mcpdo-eval-server-launcher.mjs + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +async function main() { + const configPath = process.argv[2]; + if (!configPath) { + process.stderr.write( + "mcpdo-eval-server-launcher: usage: mcpdo-eval-server-launcher.mjs \n", + ); + process.exit(2); + } + const { loadConfig, resolveConfig, TestServerStdio } = await import( + path.join(ROOT, "test-servers", "build", "index.js") + ); + const loaded = loadConfig(path.resolve(configPath)); + if (loaded.transport?.type !== "stdio") { + throw new Error( + `config transport.type must be "stdio" (got ${JSON.stringify(loaded.transport?.type)})`, + ); + } + const config = resolveConfig(loaded); + await new TestServerStdio(config).start(); + // Stdio transport holds the process open; exit is the client closing us. +} + +main().catch((err) => { + process.stderr.write(`mcpdo-eval-server-launcher: ${err?.message ?? err}\n`); + process.exit(1); +}); diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index 399644a763..1a165bc9f5 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -118,6 +118,12 @@ const TEST_SERVER_BIN = path.join( "build", "test-server-stdio.js", ); +const SERVER_LAUNCHER = path.join( + ROOT, + "scripts", + "lib", + "mcpdo-eval-server-launcher.mjs", +); const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); const RUNS = Number(process.env.RUNS ?? 3); @@ -287,11 +293,20 @@ export function behaviorAgentArgs(agent, maxTurns) { * No `MCP_ALLOW_DEFAULT_CONNECTION`: agents run non-TTY, and the explicit * connect-or-name path is the realistic one being measured. * + * The default entry is the stdio test server in its DEFAULT composition. A + * case's `server` spec swaps in a composed one instead: the `url` form + * points the entry at an already-running HTTP fixture; the config form is + * written to disk and served through the eval's stdio launcher — the + * composable framework's own path, not an extension of the default server's + * entrypoint. + * * @param {string} sandbox The sample's sandbox dir (from `makeSandbox`). + * @param {object} [server] Optional per-case server spec (see + * `validateServerSpec`). * @returns {{ env: Record, logPath: string, teardown: () => * void }} */ -export function makeBehaviorEnv(sandbox) { +export function makeBehaviorEnv(sandbox, server = undefined) { const envDir = `${sandbox}-env`; const daemonDir = path.join(envDir, "daemon"); const storageDir = path.join(envDir, "storage"); @@ -301,21 +316,32 @@ export function makeBehaviorEnv(sandbox) { mkdirSync(daemonDir, { recursive: true, mode: 0o700 }); mkdirSync(storageDir, { recursive: true }); mkdirSync(binDir, { recursive: true }); + let entry; + if (server === undefined) { + entry = { + type: "stdio", + command: process.execPath, + args: [TEST_SERVER_BIN], + }; + } else if ("url" in server) { + entry = { type: "streamable-http", url: server.url }; + } else { + const serverConfigPath = path.join(envDir, "server-config.json"); + // The launcher is always a stdio child; the case spec needn't say so + // (and validateServerSpec rejects a spec that says otherwise). + writeFileSync( + serverConfigPath, + JSON.stringify({ transport: { type: "stdio" }, ...server }, null, 2), + ); + entry = { + type: "stdio", + command: process.execPath, + args: [SERVER_LAUNCHER, serverConfigPath], + }; + } writeFileSync( catalogPath, - JSON.stringify( - { - mcpServers: { - "test-stdio": { - type: "stdio", - command: process.execPath, - args: [TEST_SERVER_BIN], - }, - }, - }, - null, - 2, - ), + JSON.stringify({ mcpServers: { "test-stdio": entry } }, null, 2), ); const shimBin = path.join(binDir, "mcpdo"); writeFileSync( @@ -369,7 +395,7 @@ export function readTranscript(logPath) { */ async function runBehaviorSample(c) { const sandbox = makeSandbox(); - const { env, logPath, teardown } = makeBehaviorEnv(sandbox); + const { env, logPath, teardown } = makeBehaviorEnv(sandbox, c.server); try { await runPrompt(c.prompt, { cwd: sandbox, diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs new file mode 100644 index 0000000000..320a7ba7c0 --- /dev/null +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -0,0 +1,225 @@ +// Tests for the mcpdo behavior eval's deterministic layer: the hermetic +// environment builder and the composed-server launcher. The model-dependent +// hit rates stay manual (`npm run skills:eval:mcpdo`); everything a hit +// depends on that is NOT a model decision is nailed down here. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + makeBehaviorEnv, + readTranscript, + loadCases, +} from "./skill-eval-mcpdo.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LAUNCHER = path.join( + ROOT, + "scripts", + "lib", + "mcpdo-eval-server-launcher.mjs", +); +const TEST_SERVERS_BUILD = path.join(ROOT, "test-servers", "build", "index.js"); + +const tempSandbox = () => + path.join(mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")), "sandbox"); + +// makeBehaviorEnv builds `${sandbox}-env`; clean both. +const cleanup = (sandbox) => { + rmSync(path.dirname(sandbox), { recursive: true, force: true }); +}; + +test( + "makeBehaviorEnv: default world shape", + { skip: process.platform === "win32" }, + () => { + const sandbox = tempSandbox(); + try { + const { env, logPath } = makeBehaviorEnv(sandbox); + const envDir = `${sandbox}-env`; + + const catalog = JSON.parse( + readFileSync(path.join(envDir, "catalog.json"), "utf8"), + ); + assert.deepEqual(Object.keys(catalog.mcpServers), ["test-stdio"]); + assert.equal(catalog.mcpServers["test-stdio"].type, "stdio"); + assert.match( + catalog.mcpServers["test-stdio"].args.join(" "), + /test-server-stdio\.js/, + ); + + // The shim shadows any globally installed mcpdo. + const shim = path.join(envDir, "bin", "mcpdo"); + assert.ok(statSync(shim).mode & 0o100, "shim must be executable"); + assert.match(readFileSync(shim, "utf8"), /mcpdo-eval-shim\.mjs/); + assert.ok(env.PATH.startsWith(path.join(envDir, "bin") + path.delimiter)); + + // The private-daemon trio plus the shim contract. + assert.equal(env.MCP_INSPECTOR_DAEMON_DIR, path.join(envDir, "daemon")); + assert.ok(env.MCP_INSPECTOR_DAEMON_TOKEN.length >= 32); + assert.equal(env.MCP_STORAGE_DIR, path.join(envDir, "storage")); + assert.equal(env.MCP_CATALOG_PATH, path.join(envDir, "catalog.json")); + assert.equal(env.MCPDO_EVAL_LOG, logPath); + assert.ok(existsSync(env.MCPDO_EVAL_REAL) || true); // path shape only + } finally { + cleanup(sandbox); + } + }, +); + +test( + "makeBehaviorEnv: url server spec points the entry at the fixture", + { skip: process.platform === "win32" }, + () => { + const sandbox = tempSandbox(); + try { + makeBehaviorEnv(sandbox, { url: "http://127.0.0.1:3999/mcp" }); + const catalog = JSON.parse( + readFileSync(path.join(`${sandbox}-env`, "catalog.json"), "utf8"), + ); + assert.deepEqual(catalog.mcpServers["test-stdio"], { + type: "streamable-http", + url: "http://127.0.0.1:3999/mcp", + }); + } finally { + cleanup(sandbox); + } + }, +); + +test( + "makeBehaviorEnv: composed server spec is written and served via the launcher", + { skip: process.platform === "win32" }, + () => { + const sandbox = tempSandbox(); + try { + const spec = { + serverInfo: { name: "composed-test", version: "1.0.0" }, + tools: [{ preset: "add" }], + }; + makeBehaviorEnv(sandbox, spec); + const envDir = `${sandbox}-env`; + const entry = JSON.parse( + readFileSync(path.join(envDir, "catalog.json"), "utf8"), + ).mcpServers["test-stdio"]; + assert.equal(entry.type, "stdio"); + assert.equal(entry.args[0], LAUNCHER); + assert.deepEqual( + JSON.parse(readFileSync(entry.args[1], "utf8")), + { transport: { type: "stdio" }, ...spec }, + "the config on disk is the case's spec plus the stdio transport", + ); + } finally { + cleanup(sandbox); + } + }, +); + +test("readTranscript: missing file and torn tail line", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); + try { + assert.deepEqual(readTranscript(path.join(dir, "absent.ndjson")), []); + const p = path.join(dir, "log.ndjson"); + const good = JSON.stringify({ argv: ["tools/list"], exit: 0, events: [] }); + writeFileSync(p, `${good}\n{"argv":["to`); + const records = readTranscript(p); + assert.equal(records.length, 1); + assert.deepEqual(records[0].argv, ["tools/list"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadCases: the committed evals file validates and partitions", () => { + const { trigger, behavior } = loadCases(); + assert.ok(trigger.length >= 5); + assert.ok(behavior.length >= 2); + assert.ok(trigger.every((c) => c.kind === "trigger")); + assert.ok(behavior.every((c) => c.kind === "behavior")); +}); + +// End-to-end: the launcher must actually SERVE the composed config. One MCP +// handshake over newline-delimited JSON-RPC, then tools/list, asserting the +// composed tool set (and only it). Skipped when the test-servers build is +// absent — the eval itself requires builds too, and says so. +test( + "launcher serves a composed config over stdio", + { skip: !existsSync(TEST_SERVERS_BUILD) || process.platform === "win32" }, + async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); + const configPath = path.join(dir, "server.json"); + writeFileSync( + configPath, + JSON.stringify({ + transport: { type: "stdio" }, + serverInfo: { name: "composed-test", version: "1.0.0" }, + tools: [{ preset: "add" }], + }), + ); + const child = spawn(process.execPath, [LAUNCHER, configPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + try { + const responses = new Map(); + let buf = ""; + let notify; + const arrived = new Promise((r) => (notify = r)); + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let nl; + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (line.trim() === "") continue; + const msg = JSON.parse(line); + if (msg.id !== undefined) { + responses.set(msg.id, msg); + notify(); + } + } + }); + const waitFor = async (id, ms = 10000) => { + const deadline = Date.now() + ms; + while (!responses.has(id)) { + if (Date.now() > deadline) { + throw new Error(`no response ${id}; stderr may explain`); + } + await new Promise((r) => setTimeout(r, 25)); + } + return responses.get(id); + }; + const send = (msg) => child.stdin.write(JSON.stringify(msg) + "\n"); + + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "eval-test", version: "0.0.0" }, + }, + }); + const init = await waitFor(1); + assert.equal(init.result.serverInfo.name, "composed-test"); + send({ jsonrpc: "2.0", method: "notifications/initialized" }); + send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const tools = (await waitFor(2)).result.tools.map((t) => t.name); + assert.deepEqual(tools, ["add"]); + } finally { + child.kill("SIGTERM"); + rmSync(dir, { recursive: true, force: true }); + } + }, +); From 64a3ef5fbb467bf159051fb4be71d044d94438d7 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 14:29:28 -0700 Subject: [PATCH 42/69] eval(mcpdo): in-process HTTP composed servers and multi-server cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Composed specs may declare transport streamable-http: the harness starts TestServerHttp IN-PROCESS (resolveConfig -> start(), free port) and the catalog entry points at its URL; teardown stops it. OAuth rides on the same instance via the spec's oauth block (requireAuth: true is the enforcement knob — asserted with AS metadata + 401/WWW-Authenticate on unauthenticated initialize). - Multi-server: `servers` name->spec map (names are catalog entry names, visible to the agent via servers/list); `server` stays as single-entry sugar. Per-name config files. validateCaseServers: mutual exclusion, non-empty map, name charset, labeled per-entry diagnostics. - No MRU strictness rule: non-TTY agents + no MCP_ALLOW_DEFAULT_CONNECTION means the daemon-cli itself rejects implicit targeting, so every successful targeting call names its connection — connection matchers stay decidable with any number of entries. - makeBehaviorEnv/teardown now async; teardown's daemon stop uses async spawn — a sync child wait while an in-process fixture is live deadlocks the event loop (found via no-model smoke, which then validated connect -> tools/call add -> {"result":5} through shim + daemon + in-process HTTP fixture). - Suite 786 -> 791. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- scripts/lib/mcpdo-eval-matchers.mjs | 79 ++++++++-- scripts/lib/mcpdo-eval-matchers.test.mjs | 59 +++++++- scripts/skill-eval-mcpdo.mjs | 169 +++++++++++++++------- scripts/skill-eval-mcpdo.test.mjs | 174 ++++++++++++++++++++++- 4 files changed, 408 insertions(+), 73 deletions(-) diff --git a/scripts/lib/mcpdo-eval-matchers.mjs b/scripts/lib/mcpdo-eval-matchers.mjs index 3fa5b410f8..766879d2c2 100644 --- a/scripts/lib/mcpdo-eval-matchers.mjs +++ b/scripts/lib/mcpdo-eval-matchers.mjs @@ -494,26 +494,77 @@ export function validateBehaviorCase(c, i) { } } }); - errors.push(...validateServerSpec(c.server, i)); + errors.push(...validateCaseServers(c, i)); return errors; } /** - * Validate a behavior case's optional `server` field: either - * `{ "url": "" }` for a server the harness does not spawn, - * or the test-servers declarative config-file shape (serverInfo + preset - * refs), which the harness writes to disk and serves through the eval's - * stdio launcher. Only the discriminating structure is checked here — preset - * names and capability switches are the framework's contract, validated by - * `resolveConfig` when the server starts. + * Validate a behavior case's server declaration: optional `server` (single + * spec under the default catalog name) or `servers` (name→spec map), never + * both. Names become catalog entry names — the agent sees them via + * `servers/list`, so they are scenario content. + * + * @param {object} c A behavior case. + * @param {number} i Case index, for error messages. + * @returns {string[]} + */ +export function validateCaseServers(c, i) { + if (c.server !== undefined && c.servers !== undefined) { + return [ + `behavior case ${i}: \`server\` and \`servers\` are mutually exclusive`, + ]; + } + if (c.servers !== undefined) { + const at = `behavior case ${i} \`servers\``; + if ( + c.servers === null || + typeof c.servers !== "object" || + Array.isArray(c.servers) + ) { + return [`${at}: must be a name→spec object`]; + } + const names = Object.keys(c.servers); + if (names.length === 0) return [`${at}: must not be empty`]; + const errors = []; + for (const name of names) { + if (!/^[A-Za-z0-9_.-]+$/.test(name)) { + errors.push(`${at}: \`${name}\` is not a valid catalog entry name`); + continue; + } + if (c.servers[name] === undefined) { + errors.push( + `${at}.${name}: spec must not be undefined (omit \`servers\` for the default server)`, + ); + continue; + } + errors.push( + ...validateServerSpec(c.servers[name], i, `\`servers\`.${name}`), + ); + } + return errors; + } + return validateServerSpec(c.server, i); +} + +/** + * Validate one server spec: either `{ "url": "" }` for a + * server the harness does not manage, or the test-servers declarative + * config-file shape (serverInfo + preset refs). A composed spec with no + * transport (or stdio) is served through the eval's stdio launcher; with + * `transport.type: "streamable-http"` it is started in-process + * (`TestServerHttp`) and the catalog entry points at its URL — OAuth via the + * spec's `oauth` block rides on the same instance. Only the discriminating + * structure is checked here — preset names and capability switches are the + * framework's contract, validated by `resolveConfig` when the server starts. * * @param {object | undefined} server * @param {number} i Case index, for error messages. + * @param {string} [label] Field label for error messages. * @returns {string[]} */ -export function validateServerSpec(server, i) { +export function validateServerSpec(server, i, label = "`server`") { if (server === undefined) return []; - const at = `behavior case ${i} \`server\``; + const at = `behavior case ${i} ${label}`; if (server === null || typeof server !== "object" || Array.isArray(server)) { return [`${at}: must be an object`]; } @@ -537,9 +588,13 @@ export function validateServerSpec(server, i) { `${at}: composed form needs \`serverInfo\` with \`name\` and \`version\` (or use the \`url\` form)`, ]; } - if (server.transport !== undefined && server.transport?.type !== "stdio") { + if ( + server.transport !== undefined && + server.transport?.type !== "stdio" && + server.transport?.type !== "streamable-http" + ) { return [ - `${at}: composed servers are spawned over stdio; omit \`transport\` (for an HTTP fixture, use the \`url\` form)`, + `${at}: composed transport must be "stdio" (default) or "streamable-http" — sse fixtures are not supported by the harness`, ]; } return []; diff --git a/scripts/lib/mcpdo-eval-matchers.test.mjs b/scripts/lib/mcpdo-eval-matchers.test.mjs index a905e5825c..2d44199eda 100644 --- a/scripts/lib/mcpdo-eval-matchers.test.mjs +++ b/scripts/lib/mcpdo-eval-matchers.test.mjs @@ -13,6 +13,7 @@ import { evalExpectCalls, streamText, validateBehaviorCase, + validateCaseServers, validateServerSpec, } from "./mcpdo-eval-matchers.mjs"; @@ -399,18 +400,72 @@ test("validateServerSpec: url form, composed form, and rejects", () => { /needs `serverInfo`/.test(e), ), ); + assert.deepEqual( + validateServerSpec( + { + serverInfo: { name: "protected-api", version: "1.0.0" }, + transport: { type: "streamable-http" }, + oauth: { enabled: true, mode: "combined" }, + }, + 0, + ), + [], + "http composed form (incl. oauth) is valid", + ); assert.ok( validateServerSpec( { serverInfo: { name: "c", version: "1" }, - transport: { type: "streamable-http" }, + transport: { type: "sse" }, }, 0, - ).some((e) => /omit `transport`/.test(e)), + ).some((e) => /sse fixtures are not supported/.test(e)), ); assert.ok(validateServerSpec([], 0).some((e) => /must be an object/.test(e))); }); +test("validateCaseServers: map form, exclusivity, and per-entry labels", () => { + const spec = { serverInfo: { name: "s", version: "1" } }; + assert.deepEqual( + validateCaseServers( + { servers: { calendar: spec, "weather-api": { url: "http://x/mcp" } } }, + 0, + ), + [], + ); + assert.ok( + validateCaseServers({ server: spec, servers: { a: spec } }, 0).some((e) => + /mutually exclusive/.test(e), + ), + ); + assert.ok( + validateCaseServers({ servers: {} }, 0).some((e) => + /must not be empty/.test(e), + ), + ); + assert.ok( + validateCaseServers({ servers: ["x"] }, 0).some((e) => + /name→spec object/.test(e), + ), + ); + assert.ok( + validateCaseServers({ servers: { "bad name!": spec } }, 0).some((e) => + /not a valid catalog entry name/.test(e), + ), + ); + assert.ok( + validateCaseServers({ servers: { a: undefined } }, 0).some((e) => + /must not be undefined/.test(e), + ), + ); + // Nested spec errors carry the entry name. + assert.ok( + validateCaseServers({ servers: { alpha: { url: "ftp://x" } } }, 3).some( + (e) => /behavior case 3 `servers`\.alpha/.test(e), + ), + ); +}); + test("validateBehaviorCase: server field is validated through the case", () => { const errs = validateBehaviorCase( { prompt: "p", expectCalls: [{ cmd: "connect" }], server: { url: "nope" } }, diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index 1a165bc9f5..bb7b4008ca 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -62,7 +62,7 @@ // AGENT=copilot npm run skills:eval:mcpdo // BEHAVIOR_RUNS=4 BEHAVIOR_THRESHOLD=0.75 npm run skills:eval:mcpdo -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { chmodSync, cpSync, @@ -279,34 +279,54 @@ export function behaviorAgentArgs(agent, maxTurns) { ]; } +/** + * Normalize a behavior case's server declaration to a name→spec map. + * `servers` wins (validation forbids both); `server` is sugar for a single + * entry under the default name; neither means the default composition. + * + * @param {object} c A behavior case. + * @returns {Record} + */ +export function caseServers(c) { + if (c.servers !== undefined) return c.servers; + return { "test-stdio": c.server }; +} + /** * Build one behavior sample's hermetic mcpdo world, next to (not inside) its * sandbox so the agent's cwd stays clean. * * Private daemon binding (own dir + minted token — the same isolation * `mcpdo private` gives a shell), throwaway storage, a catalog holding - * exactly `test-stdio`, and a bin dir whose `mcpdo` is the recording shim. - * One entry on purpose: with a single catalog entry, an implicit-MRU call - * can only mean the right server, which is what lets the `connection` - * matcher accept the flag's absence. + * exactly the case's servers, and a bin dir whose `mcpdo` is the recording + * shim. * - * No `MCP_ALLOW_DEFAULT_CONNECTION`: agents run non-TTY, and the explicit - * connect-or-name path is the realistic one being measured. + * No `MCP_ALLOW_DEFAULT_CONNECTION`: agents run non-TTY, so the daemon-cli + * itself refuses implicit-MRU targeting (`requireExplicitConnection`) — + * every successful targeting call in a transcript names its connection, + * which is what keeps `connection` matchers decidable even with several + * catalog entries. * - * The default entry is the stdio test server in its DEFAULT composition. A - * case's `server` spec swaps in a composed one instead: the `url` form - * points the entry at an already-running HTTP fixture; the config form is - * written to disk and served through the eval's stdio launcher — the - * composable framework's own path, not an extension of the default server's - * entrypoint. + * Per-entry spec forms (see `validateServerSpec`): undefined → the stdio + * test server in its DEFAULT composition; `{url}` → an already-running HTTP + * fixture; composed with stdio (or no) transport → config on disk, served + * through the eval's stdio launcher; composed with streamable-http + * transport → started IN-PROCESS (`TestServerHttp`) and the entry points at + * its URL. In-process because nothing forces a process boundary for HTTP + * (the daemon only spawns stdio commands), the fixture can't pollute the + * transcript (the shim records only mcpdo invocations), and teardown is a + * direct `stop()`. OAuth rides on the same instance (`oauth` in the spec). * * @param {string} sandbox The sample's sandbox dir (from `makeSandbox`). - * @param {object} [server] Optional per-case server spec (see - * `validateServerSpec`). - * @returns {{ env: Record, logPath: string, teardown: () => - * void }} + * @param {Record} [servers] Name→spec map (from + * `caseServers`). + * @returns {Promise<{ env: Record, logPath: string, + * teardown: () => Promise }>} */ -export function makeBehaviorEnv(sandbox, server = undefined) { +export async function makeBehaviorEnv( + sandbox, + servers = { "test-stdio": undefined }, +) { const envDir = `${sandbox}-env`; const daemonDir = path.join(envDir, "daemon"); const storageDir = path.join(envDir, "storage"); @@ -316,33 +336,58 @@ export function makeBehaviorEnv(sandbox, server = undefined) { mkdirSync(daemonDir, { recursive: true, mode: 0o700 }); mkdirSync(storageDir, { recursive: true }); mkdirSync(binDir, { recursive: true }); - let entry; - if (server === undefined) { - entry = { - type: "stdio", - command: process.execPath, - args: [TEST_SERVER_BIN], - }; - } else if ("url" in server) { - entry = { type: "streamable-http", url: server.url }; - } else { - const serverConfigPath = path.join(envDir, "server-config.json"); - // The launcher is always a stdio child; the case spec needn't say so - // (and validateServerSpec rejects a spec that says otherwise). - writeFileSync( - serverConfigPath, - JSON.stringify({ transport: { type: "stdio" }, ...server }, null, 2), - ); - entry = { - type: "stdio", - command: process.execPath, - args: [SERVER_LAUNCHER, serverConfigPath], - }; + /** In-process HTTP fixtures to stop at teardown. */ + const httpServers = []; + const entries = {}; + try { + for (const [name, spec] of Object.entries(servers)) { + if (spec === undefined) { + entries[name] = { + type: "stdio", + command: process.execPath, + args: [TEST_SERVER_BIN], + }; + } else if ("url" in spec) { + entries[name] = { type: "streamable-http", url: spec.url }; + } else if (spec.transport?.type === "streamable-http") { + const serverConfigPath = path.join( + envDir, + `server-config-${name}.json`, + ); + writeFileSync(serverConfigPath, JSON.stringify(spec, null, 2)); + const { loadConfig, resolveConfig, TestServerHttp } = await import( + path.join(ROOT, "test-servers", "build", "index.js") + ); + const server = new TestServerHttp( + resolveConfig(loadConfig(serverConfigPath)), + ); + await server.start(); + httpServers.push(server); + entries[name] = { type: "streamable-http", url: server.url }; + } else { + const serverConfigPath = path.join( + envDir, + `server-config-${name}.json`, + ); + // The launcher is always a stdio child; the case spec needn't say so + // (and validateServerSpec rejects a spec that says otherwise). + writeFileSync( + serverConfigPath, + JSON.stringify({ transport: { type: "stdio" }, ...spec }, null, 2), + ); + entries[name] = { + type: "stdio", + command: process.execPath, + args: [SERVER_LAUNCHER, serverConfigPath], + }; + } + } + } catch (err) { + for (const s of httpServers) await s.stop().catch(() => {}); + rmSync(envDir, { recursive: true, force: true }); + throw err; } - writeFileSync( - catalogPath, - JSON.stringify({ mcpServers: { "test-stdio": entry } }, null, 2), - ); + writeFileSync(catalogPath, JSON.stringify({ mcpServers: entries }, null, 2)); const shimBin = path.join(binDir, "mcpdo"); writeFileSync( shimBin, @@ -358,14 +403,29 @@ export function makeBehaviorEnv(sandbox, server = undefined) { MCPDO_EVAL_REAL: REAL_BIN, MCPDO_EVAL_LOG: logPath, }; - const teardown = () => { - // Direct spawn of the real build, not the shim: teardown must not - // appear in the transcript, and must work even if the shim is broken. - spawnSync(process.execPath, [REAL_BIN, "daemon", "stop"], { - env: { ...process.env, ...env }, - timeout: 15000, - stdio: "ignore", + const teardown = async () => { + // Daemon first (it may hold connections into the HTTP fixtures), then + // the fixtures. Direct spawn of the real build, not the shim: teardown + // must not appear in the transcript, and must work even if the shim is + // broken. Async spawn, NOT spawnSync — the in-process fixtures share + // this event loop, and a synchronous wait would deadlock any daemon + // shutdown that talks to them (measured: the sync variant stalled). + await new Promise((resolve) => { + const child = spawn(process.execPath, [REAL_BIN, "daemon", "stop"], { + env: { ...process.env, ...env }, + stdio: "ignore", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 15000); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.on("error", () => { + clearTimeout(timer); + resolve(); + }); }); + for (const s of httpServers) await s.stop().catch(() => {}); rmSync(envDir, { recursive: true, force: true }); }; return { env, logPath, teardown }; @@ -395,7 +455,10 @@ export function readTranscript(logPath) { */ async function runBehaviorSample(c) { const sandbox = makeSandbox(); - const { env, logPath, teardown } = makeBehaviorEnv(sandbox, c.server); + const { env, logPath, teardown } = await makeBehaviorEnv( + sandbox, + caseServers(c), + ); try { await runPrompt(c.prompt, { cwd: sandbox, @@ -408,7 +471,7 @@ async function runBehaviorSample(c) { const { ok, failures } = evalExpectCalls(c.expectCalls, records); return { hit: ok, failures, calls: records.length }; } finally { - teardown(); + await teardown(); rmSync(sandbox, { recursive: true, force: true }); } } diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs index 320a7ba7c0..f388ed8fd7 100644 --- a/scripts/skill-eval-mcpdo.test.mjs +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -18,6 +18,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + caseServers, makeBehaviorEnv, readTranscript, loadCases, @@ -43,10 +44,10 @@ const cleanup = (sandbox) => { test( "makeBehaviorEnv: default world shape", { skip: process.platform === "win32" }, - () => { + async () => { const sandbox = tempSandbox(); try { - const { env, logPath } = makeBehaviorEnv(sandbox); + const { env, logPath } = await makeBehaviorEnv(sandbox); const envDir = `${sandbox}-env`; const catalog = JSON.parse( @@ -81,10 +82,13 @@ test( test( "makeBehaviorEnv: url server spec points the entry at the fixture", { skip: process.platform === "win32" }, - () => { + async () => { const sandbox = tempSandbox(); try { - makeBehaviorEnv(sandbox, { url: "http://127.0.0.1:3999/mcp" }); + await makeBehaviorEnv( + sandbox, + caseServers({ server: { url: "http://127.0.0.1:3999/mcp" } }), + ); const catalog = JSON.parse( readFileSync(path.join(`${sandbox}-env`, "catalog.json"), "utf8"), ); @@ -101,14 +105,14 @@ test( test( "makeBehaviorEnv: composed server spec is written and served via the launcher", { skip: process.platform === "win32" }, - () => { + async () => { const sandbox = tempSandbox(); try { const spec = { serverInfo: { name: "composed-test", version: "1.0.0" }, tools: [{ preset: "add" }], }; - makeBehaviorEnv(sandbox, spec); + await makeBehaviorEnv(sandbox, caseServers({ server: spec })); const envDir = `${sandbox}-env`; const entry = JSON.parse( readFileSync(path.join(envDir, "catalog.json"), "utf8"), @@ -126,6 +130,164 @@ test( }, ); +test("caseServers: normalizes server / servers / neither", () => { + const spec = { serverInfo: { name: "s", version: "1" } }; + assert.deepEqual(caseServers({}), { "test-stdio": undefined }); + assert.deepEqual(caseServers({ server: spec }), { "test-stdio": spec }); + assert.deepEqual(caseServers({ servers: { a: spec } }), { a: spec }); +}); + +test( + "makeBehaviorEnv: multiple servers get their own entries and config files", + { skip: process.platform === "win32" }, + async () => { + const sandbox = tempSandbox(); + try { + const calendar = { + serverInfo: { name: "calendar", version: "1.0.0" }, + tools: [{ preset: "add" }], + }; + await makeBehaviorEnv(sandbox, { + calendar, + "weather-api": { url: "http://127.0.0.1:3999/mcp" }, + }); + const envDir = `${sandbox}-env`; + const catalog = JSON.parse( + readFileSync(path.join(envDir, "catalog.json"), "utf8"), + ); + assert.deepEqual(Object.keys(catalog.mcpServers).sort(), [ + "calendar", + "weather-api", + ]); + const cal = catalog.mcpServers.calendar; + assert.equal(cal.args[0], LAUNCHER); + assert.match(cal.args[1], /server-config-calendar\.json$/); + assert.deepEqual(catalog.mcpServers["weather-api"], { + type: "streamable-http", + url: "http://127.0.0.1:3999/mcp", + }); + } finally { + cleanup(sandbox); + } + }, +); + +// In-process HTTP fixture: a composed spec with streamable-http transport +// must come up inside the harness, get a catalog entry pointing at its live +// URL, answer an MCP initialize over HTTP, and die at teardown. OAuth rides +// on the same instance, so its AS metadata endpoint is asserted too. +test( + "makeBehaviorEnv: http composed server runs in-process (with oauth metadata)", + { skip: !existsSync(TEST_SERVERS_BUILD) || process.platform === "win32" }, + async () => { + const sandbox = tempSandbox(); + let teardown; + try { + const env = await makeBehaviorEnv(sandbox, { + "protected-api": { + transport: { type: "streamable-http" }, + serverInfo: { name: "protected-api", version: "1.0.0" }, + tools: [{ preset: "add" }], + oauth: { enabled: true, mode: "combined", requireAuth: true }, + }, + }); + teardown = env.teardown; + const entry = JSON.parse( + readFileSync(path.join(`${sandbox}-env`, "catalog.json"), "utf8"), + ).mcpServers["protected-api"]; + assert.equal(entry.type, "streamable-http"); + assert.match(entry.url, /^http:\/\/localhost:\d+\/mcp$/); + + const origin = entry.url.replace(/\/mcp$/, ""); + const meta = await fetch( + `${origin}/.well-known/oauth-authorization-server`, + ); + assert.equal(meta.status, 200); + const asMeta = await meta.json(); + assert.equal(asMeta.issuer, origin); + assert.ok(asMeta.authorization_endpoint.startsWith(origin)); + + // Unauthenticated MCP request → the protected resource must challenge, + // not serve (401 + WWW-Authenticate), proving oauth guards the entry + // the catalog points at. + const res = await fetch(entry.url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "eval-test", version: "0.0.0" }, + }, + }), + }); + assert.equal(res.status, 401); + assert.ok(res.headers.get("www-authenticate")); + + await teardown(); + teardown = undefined; + await assert.rejects( + fetch(`${origin}/.well-known/oauth-authorization-server`), + undefined, + "fixture must be gone after teardown", + ); + } finally { + if (teardown) await teardown(); + cleanup(sandbox); + } + }, +); + +test( + "makeBehaviorEnv: plain http composed server answers initialize", + { skip: !existsSync(TEST_SERVERS_BUILD) || process.platform === "win32" }, + async () => { + const sandbox = tempSandbox(); + let teardown; + try { + const env = await makeBehaviorEnv(sandbox, { + api: { + transport: { type: "streamable-http" }, + serverInfo: { name: "plain-api", version: "1.0.0" }, + tools: [{ preset: "add" }], + }, + }); + teardown = env.teardown; + const entry = JSON.parse( + readFileSync(path.join(`${sandbox}-env`, "catalog.json"), "utf8"), + ).mcpServers.api; + const res = await fetch(entry.url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "eval-test", version: "0.0.0" }, + }, + }), + }); + assert.equal(res.status, 200); + assert.match(await res.text(), /"name":\s*"plain-api"/); + } finally { + if (teardown) await teardown(); + cleanup(sandbox); + } + }, +); + test("readTranscript: missing file and torn tail line", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); try { From 7b718faa22fa79b18404ae63169bd26cefc03615 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 15:37:55 -0700 Subject: [PATCH 43/69] mcpdo: non-TTY connect exits with the auth link; detached helper completes sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents drive mcpdo over pipes, where the blocking interactive OAuth flow is hostile: the auth URL sits invisible in a buffered foreground pipe, and a timeout kill tears down the loopback callback listener the URL points at, staling the link (observed live in a Claude Code session). On connect, when stdin AND stderr are non-TTY (and MCP_AUTO_OPEN_ENABLED is not "true"), mcpdo now: - spawns a detached helper (hidden auth/complete-signin subcommand; params as JSON over stdin, never argv) that owns the callback listener and token exchange, bounded by the flow's own 15-minute callback wait; - registers the connection in the daemon as a pending intent entry (ConnectParams.pendingOnAuthRequired): a never-connected client whose terminal status makes the first real op complete the connection through the existing revive path once tokens land; - exits 0 immediately with pendingAuth: true and the authorize URL in the normal output payload (authUrl in JSON; relay-worded block in human output). The URL cannot ride the error envelope: error-path redaction strips URL query strings, which is exactly where client_id/PKCE/state live. Repeat connects while a helper is waiting reuse its URL via a pid+expiry validated 0600 marker file in the daemon dir — minting a second flow would collide on the fixed callback port and stale the user's held link. connections/show recomputes auth from disk, so it doubles as the sign-in poll; list/show/connect surface the pending state. TTY and --stored-auth-only behavior is unchanged. Validated end-to-end against the composable OAuth fixture (requireAuth + DCR): non-TTY connect → exit 0 with URL → consent click → helper stored tokens → tools/call revived and succeeded with pendingAuth cleared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/auth-helper.test.ts | 319 ++++++++++++++++++ .../__tests__/daemon-connections.test.ts | 110 ++++++ .../__tests__/format-connection.test.ts | 50 +++ .../__tests__/mcp-auth-coverage.test.ts | 138 ++++++++ .../daemon-cli/src/connection/auth-helper.ts | 308 +++++++++++++++++ .../daemon-cli/src/connection/authorize.ts | 32 +- .../src/connection/format-connection.ts | 35 +- .../daemon-cli/src/connection/format-human.ts | 9 +- clients/daemon-cli/src/connection/mcp.ts | 46 +++ clients/daemon-cli/src/daemon/connections.ts | 62 +++- clients/daemon-cli/src/daemon/protocol.ts | 20 ++ clients/daemon-cli/src/daemon/server.ts | 1 + 12 files changed, 1101 insertions(+), 29 deletions(-) create mode 100644 clients/daemon-cli/__tests__/auth-helper.test.ts create mode 100644 clients/daemon-cli/src/connection/auth-helper.ts diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts new file mode 100644 index 0000000000..c1bea16bb1 --- /dev/null +++ b/clients/daemon-cli/__tests__/auth-helper.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { PassThrough } from "node:stream"; +import type { CallbackNavigation } from "@inspector/core/auth/index.js"; + +const authorizeInFrontend = vi.fn(); + +vi.mock("../src/connection/authorize.js", () => ({ + authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), +})); + +import { + AUTH_HELPER_COMMAND, + obtainPendingAuthUrl, + pendingAuthMarkerPath, + readLivePendingAuthMarker, + runAuthHelper, + type PendingAuthMarker, +} from "../src/connection/auth-helper.js"; + +const SERVER_URL = "https://mcp.example.com/mcp"; + +describe("auth-helper", () => { + let dir: string; + let prevDaemonDir: string | undefined; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-helper-")); + prevDaemonDir = process.env.MCP_INSPECTOR_DAEMON_DIR; + process.env.MCP_INSPECTOR_DAEMON_DIR = dir; + authorizeInFrontend.mockReset(); + }); + + afterEach(() => { + if (prevDaemonDir === undefined) + delete process.env.MCP_INSPECTOR_DAEMON_DIR; + else process.env.MCP_INSPECTOR_DAEMON_DIR = prevDaemonDir; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function writeMarker(marker: PendingAuthMarker): string { + const markerPath = pendingAuthMarkerPath(SERVER_URL); + fs.writeFileSync(markerPath, JSON.stringify(marker), { mode: 0o600 }); + return markerPath; + } + + describe("readLivePendingAuthMarker", () => { + it("returns undefined when no marker exists", () => { + expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); + }); + + it("returns a live marker (unexpired, helper pid running)", () => { + writeMarker({ + url: "https://as.example/authorize?state=s1", + pid: process.pid, + expiresAt: Date.now() + 60_000, + }); + expect(readLivePendingAuthMarker(SERVER_URL)).toMatchObject({ + url: "https://as.example/authorize?state=s1", + }); + }); + + it("removes and ignores an expired marker", () => { + const markerPath = writeMarker({ + url: "https://as.example/authorize", + pid: process.pid, + expiresAt: Date.now() - 1, + }); + expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); + expect(fs.existsSync(markerPath)).toBe(false); + }); + + it("removes and ignores a marker whose helper process is gone", () => { + const markerPath = writeMarker({ + url: "https://as.example/authorize", + // Out-of-range / nonexistent pid: process.kill(pid, 0) throws. + pid: 0x7fffffff, + expiresAt: Date.now() + 60_000, + }); + expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); + expect(fs.existsSync(markerPath)).toBe(false); + }); + + it("ignores malformed marker files", () => { + fs.writeFileSync(pendingAuthMarkerPath(SERVER_URL), "not-json"); + expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); + fs.writeFileSync(pendingAuthMarkerPath(SERVER_URL), '{"url":42}'); + expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); + }); + }); + + describe("obtainPendingAuthUrl", () => { + function writeHelperScript(body: string): string { + const script = path.join(dir, "fake-helper.mjs"); + fs.writeFileSync(script, body); + return script; + } + + it("reuses a live marker's URL without spawning a second helper", async () => { + writeMarker({ + url: "https://as.example/authorize?state=reuse", + pid: process.pid, + expiresAt: Date.now() + 60_000, + }); + const url = await obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + // Would fail loudly if a spawn were attempted. + { helperArgv1: path.join(dir, "does-not-exist.mjs") }, + ); + expect(url).toBe("https://as.example/authorize?state=reuse"); + }); + + it("spawns the helper, passes params over stdin, and returns the reported URL", async () => { + const script = writeHelperScript(` + let body = ""; + process.stdin.on("data", (c) => (body += c)); + process.stdin.on("end", () => { + const params = JSON.parse(body); + if (process.argv[2] !== ${JSON.stringify(AUTH_HELPER_COMMAND)}) { + process.exit(9); + } + process.stdout.write( + JSON.stringify({ + event: "auth_url", + url: "https://as.example/authorize?server=" + + encodeURIComponent(params.serverConfig.url), + }) + "\\n", + ); + }); + `); + const url = await obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: script }, + ); + expect(url).toBe( + `https://as.example/authorize?server=${encodeURIComponent(SERVER_URL)}`, + ); + }); + + it("maps a helper error event to auth_required", async () => { + const script = writeHelperScript(` + process.stdin.resume(); + process.stdin.on("end", () => { + process.stdout.write( + JSON.stringify({ event: "error", message: "no AS metadata" }) + "\\n", + ); + }); + `); + await expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: script }, + ), + ).rejects.toMatchObject({ + envelope: { code: "auth_required" }, + message: expect.stringContaining("no AS metadata"), + }); + }); + + it("fails when the helper exits before producing a URL", async () => { + const script = writeHelperScript(`process.exit(2);`); + await expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: script }, + ), + ).rejects.toMatchObject({ + envelope: { code: "auth_required" }, + message: expect.stringContaining("exited"), + }); + }); + }); + + describe("runAuthHelper", () => { + function stubStdin(body: string): () => void { + const stream = new PassThrough(); + const descriptor = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { + value: stream, + configurable: true, + }); + stream.end(body); + return () => { + if (descriptor) Object.defineProperty(process, "stdin", descriptor); + }; + } + + function captureStdout(): { lines: () => string[]; restore: () => void } { + let out = ""; + const original = process.stdout.write; + process.stdout.write = ((chunk: unknown) => { + out += typeof chunk === "string" ? chunk : String(chunk); + return true; + }) as typeof process.stdout.write; + return { + lines: () => + out + .split("\n") + .filter((l) => l.trim()) + .map((l) => l), + restore: () => { + process.stdout.write = original; + }, + }; + } + + it("writes the marker while the flow runs, emits auth_url and done, and removes the marker on exit", async () => { + let markerDuringFlow: PendingAuthMarker | undefined; + authorizeInFrontend.mockImplementation( + async ( + _config: unknown, + _settings: unknown, + options: { + makeNavigation: (control: { armed: boolean }) => CallbackNavigation; + }, + ) => { + const navigation = options.makeNavigation({ armed: true }); + navigation.navigateToAuthorization( + new URL("https://as.example/authorize?state=s2"), + ); + markerDuringFlow = readLivePendingAuthMarker(SERVER_URL); + }, + ); + const restoreStdin = stubStdin( + JSON.stringify({ + serverConfig: { type: "streamable-http", url: SERVER_URL }, + }), + ); + const stdout = captureStdout(); + try { + await runAuthHelper(); + } finally { + stdout.restore(); + restoreStdin(); + } + expect(markerDuringFlow).toMatchObject({ + url: "https://as.example/authorize?state=s2", + pid: process.pid, + }); + const events = stdout + .lines() + .map((l) => JSON.parse(l) as { event: string }); + expect(events.map((e) => e.event)).toEqual(["auth_url", "done"]); + // Marker removed once the flow completed. + expect(fs.existsSync(pendingAuthMarkerPath(SERVER_URL))).toBe(false); + }); + + it("stays silent while the navigation is disarmed (SDK auth during plain connect)", async () => { + authorizeInFrontend.mockImplementation( + async ( + _config: unknown, + _settings: unknown, + options: { + makeNavigation: (control: { armed: boolean }) => CallbackNavigation; + }, + ) => { + const navigation = options.makeNavigation({ armed: false }); + navigation.navigateToAuthorization( + new URL("https://as.example/authorize?leaked=1"), + ); + }, + ); + const restoreStdin = stubStdin( + JSON.stringify({ + serverConfig: { type: "streamable-http", url: SERVER_URL }, + }), + ); + const stdout = captureStdout(); + try { + await runAuthHelper(); + } finally { + stdout.restore(); + restoreStdin(); + } + const events = stdout + .lines() + .map((l) => JSON.parse(l) as { event: string }); + expect(events.map((e) => e.event)).toEqual(["done"]); + expect(fs.existsSync(pendingAuthMarkerPath(SERVER_URL))).toBe(false); + }); + + it("emits an error event and rethrows when the flow fails", async () => { + authorizeInFrontend.mockRejectedValueOnce(new Error("flow exploded")); + const restoreStdin = stubStdin( + JSON.stringify({ + serverConfig: { type: "streamable-http", url: SERVER_URL }, + }), + ); + const stdout = captureStdout(); + try { + await expect(runAuthHelper()).rejects.toThrow("flow exploded"); + } finally { + stdout.restore(); + restoreStdin(); + } + const events = stdout + .lines() + .map((l) => JSON.parse(l) as { event: string; message?: string }); + expect(events).toEqual([{ event: "error", message: "flow exploded" }]); + }); + + it("rejects params without a serverConfig", async () => { + const restoreStdin = stubStdin(JSON.stringify({})); + const stdout = captureStdout(); + try { + await expect(runAuthHelper()).rejects.toThrow(/serverConfig/); + } finally { + stdout.restore(); + restoreStdin(); + } + }); + }); +}); diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 7ae07340ed..71854cf62f 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -404,6 +404,116 @@ describe("ConnectionRegistry", () => { } }); + it("pendingOnAuthRequired registers a dormant intent entry that completes via revive on first use", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + // Dial: no stored tokens yet — auth_required. + .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 })) + // Revive after the helper stored tokens: succeeds. + .mockResolvedValueOnce(undefined); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + let pendingClient: unknown; + const statusSpy = vi + .spyOn(InspectorClient.prototype, "getStatus") + .mockImplementation(function (this: unknown) { + // The pending entry's client never connected (terminal status → + // revivable); the revived client is live. + return this === pendingClient ? "disconnected" : "connected"; + }); + try { + const info = await registry.connect({ + name: "p", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + pendingOnAuthRequired: true, + }); + // Registered as pending intent instead of throwing. + expect(info.pendingAuth).toBe(true); + expect(info.auth).toEqual({ method: "oauth", authorized: false }); + expect(registry.connectionCount()).toBe(1); + expect(registry.list()[0]).toMatchObject({ + name: "p", + pendingAuth: true, + }); + pendingClient = registry.clientFor("p", false); + + // First op after tokens land: revive dials and clears the flag. + const revived = await registry.liveClientFor("p", false); + expect(revived).not.toBe(pendingClient); + expect(registry.list()[0]?.pendingAuth).toBeUndefined(); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + statusSpy.mockRestore(); + } + }); + + it("pendingOnAuthRequired only swallows auth_required — other dial failures still throw with no entry", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:443")); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const registry = new ConnectionRegistry(0); + try { + await expect( + registry.connect({ + name: "p", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + pendingOnAuthRequired: true, + }), + ).rejects.toThrow(/ECONNREFUSED/); + expect(registry.connectionCount()).toBe(0); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + } + }); + + it("without pendingOnAuthRequired, an auth_required dial still throws with no entry", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 })); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const registry = new ConnectionRegistry(0); + try { + await expect( + registry.connect({ + name: "p", + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }), + ).rejects.toMatchObject({ envelope: { code: "auth_required" } }); + expect(registry.connectionCount()).toBe(0); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + } + }); + it("a connect that outlives shutdown's quiesce grace tears its client down instead of leaking it", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); let releaseConnect!: () => void; diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index e87ebbe91e..ea3357ddbd 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -608,6 +608,56 @@ describe("writeConnectionOutput", () => { process.stderr.write = originalErr; }); + it("connection with authUrl: json carries the URL verbatim (query intact), human prints relay guidance", async () => { + const authUrl = "https://as.example/authorize?client_id=abc&state=xyz"; + await writeConnectionOutput( + { format: "json" }, + { + kind: "connection", + connection: { + name: "api", + serverIdentity: "https://mcp.example.com/mcp", + pendingAuth: true, + auth: { method: "oauth", authorized: false }, + }, + authUrl, + }, + ); + const parsed = JSON.parse(stdout) as Record; + expect(parsed.pendingAuth).toBe(true); + expect(parsed.authUrl).toBe(authUrl); + + stdout = ""; + await writeConnectionOutput( + { format: "text" }, + { + kind: "connection", + connection: { + name: "api", + serverIdentity: "https://mcp.example.com/mcp", + pendingAuth: true, + auth: { method: "oauth", authorized: false }, + }, + authUrl, + }, + ); + expect(stdout).toContain("Sign-in required"); + expect(stdout).toContain(authUrl); + expect(stdout).toContain("Sign-in: pending"); + expect(stdout).toContain("connections/show @api"); + }); + + it("connection without authUrl renders exactly as before (no sign-in block)", async () => { + await writeConnectionOutput( + { format: "text" }, + { + kind: "connection", + connection: { name: "api", serverIdentity: "id" }, + }, + ); + expect(stdout).not.toContain("Sign-in"); + }); + it("pretty-prints json without a result envelope", async () => { await writeConnectionOutput( { format: "json" }, diff --git a/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts index 7bffe7b51b..5f245265f5 100644 --- a/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts +++ b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts @@ -8,6 +8,7 @@ import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; const callDaemon = vi.fn(); const ensureDaemon = vi.fn(); const authorizeInFrontend = vi.fn(); +const obtainPendingAuthUrl = vi.fn(); vi.mock("../src/daemon/index.js", () => ({ callDaemon: (...args: unknown[]) => callDaemon(...args), @@ -19,6 +20,12 @@ vi.mock("../src/connection/authorize.js", () => ({ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), })); +vi.mock("../src/connection/auth-helper.js", () => ({ + AUTH_HELPER_COMMAND: "auth/complete-signin", + runAuthHelper: vi.fn(), + obtainPendingAuthUrl: (...args: unknown[]) => obtainPendingAuthUrl(...args), +})); + describe("mcp.ts auth / daemon error paths", () => { let configPath: string | undefined; let stdout: string; @@ -50,9 +57,15 @@ describe("mcp.ts auth / daemon error paths", () => { callDaemon.mockReset(); authorizeInFrontend.mockReset(); authorizeInFrontend.mockResolvedValue(undefined); + obtainPendingAuthUrl.mockReset(); }); + const originalStderrIsTTY = process.stderr.isTTY; + const originalStdinIsTTY = process.stdin.isTTY; + afterEach(() => { + process.stderr.isTTY = originalStderrIsTTY; + process.stdin.isTTY = originalStdinIsTTY; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; if (configPath) { @@ -90,6 +103,7 @@ describe("mcp.ts auth / daemon error paths", () => { }); it("retries connect after auth_required via authorizeInFrontend", async () => { + process.stderr.isTTY = true; // human path: blocking interactive OAuth configPath = createSampleTestConfig(); const connection = { name: "test-stdio", @@ -122,6 +136,7 @@ describe("mcp.ts auth / daemon error paths", () => { }); it("re-ensures the daemon after authorizeInFrontend, in case interactive OAuth outlasted its idle timeout", async () => { + process.stderr.isTTY = true; // human path: blocking interactive OAuth configPath = createSampleTestConfig(); const connection = { name: "test-stdio", @@ -164,6 +179,129 @@ describe("mcp.ts auth / daemon error paths", () => { }); }); + it("non-TTY connect on auth_required: hands off to the helper, registers a pending entry, and prints the auth URL", async () => { + // Agent path: no TTY on stdin or stderr. + process.stdin.isTTY = undefined as unknown as boolean; + process.stderr.isTTY = undefined as unknown as boolean; + configPath = createSampleTestConfig(); + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce({ + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + pendingAuth: true, + auth: { method: "oauth", authorized: false }, + }); + obtainPendingAuthUrl.mockResolvedValueOnce( + "https://as.example/authorize?client_id=abc&state=xyz", + ); + + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp([ + "node", + "mcpdo", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + // Never the blocking interactive flow on the agent path. + expect(authorizeInFrontend).not.toHaveBeenCalled(); + expect(obtainPendingAuthUrl).toHaveBeenCalledOnce(); + // The re-dial carries the pending-intent flag. + const second = callDaemon.mock.calls[1]; + expect(second[0]).toBe("connect"); + expect(second[1]).toMatchObject({ pendingOnAuthRequired: true }); + // The auth URL rides the normal JSON payload, query string intact + // (the error envelope would redact it). + const out = JSON.parse(stdout.trim()) as Record; + expect(out.pendingAuth).toBe(true); + expect(out.authUrl).toBe( + "https://as.example/authorize?client_id=abc&state=xyz", + ); + }); + + it("non-TTY connect omits authUrl when the pending re-dial actually connected (sign-in already finished)", async () => { + process.stdin.isTTY = undefined as unknown as boolean; + process.stderr.isTTY = undefined as unknown as boolean; + configPath = createSampleTestConfig(); + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce({ + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + auth: { method: "oauth", authorized: true }, + }); + obtainPendingAuthUrl.mockResolvedValueOnce("https://as.example/authorize"); + + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp([ + "node", + "mcpdo", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + const out = JSON.parse(stdout.trim()) as Record; + expect(out.pendingAuth).toBeUndefined(); + expect(out.authUrl).toBeUndefined(); + }); + + it("MCP_AUTO_OPEN_ENABLED=true keeps the blocking interactive flow even without a TTY", async () => { + process.stdin.isTTY = undefined as unknown as boolean; + process.stderr.isTTY = undefined as unknown as boolean; + const prev = process.env.MCP_AUTO_OPEN_ENABLED; + process.env.MCP_AUTO_OPEN_ENABLED = "true"; + configPath = createSampleTestConfig(); + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce({ + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + }); + + try { + const { runMcp } = await import("../src/connection/mcp.js"); + await runMcp([ + "node", + "mcpdo", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + expect(authorizeInFrontend).toHaveBeenCalledOnce(); + expect(obtainPendingAuthUrl).not.toHaveBeenCalled(); + } finally { + if (prev === undefined) delete process.env.MCP_AUTO_OPEN_ENABLED; + else process.env.MCP_AUTO_OPEN_ENABLED = prev; + } + }); + it("rejects --relogin with --stored-auth-only", async () => { configPath = createSampleTestConfig(); const { runMcp } = await import("../src/connection/mcp.js"); diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts new file mode 100644 index 0000000000..d95bd906fd --- /dev/null +++ b/clients/daemon-cli/src/connection/auth-helper.ts @@ -0,0 +1,308 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { CallbackNavigation } from "@inspector/core/auth/index.js"; +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonDir } from "../daemon/paths.js"; +import { authorizeInFrontend } from "./authorize.js"; + +/** + * Detached OAuth completion helper for the non-TTY `connect` path. + * + * An agent driving mcpdo over pipes cannot sit on a blocking interactive + * OAuth flow: the auth URL stays invisible in a buffered foreground pipe, and + * killing the foreground process would tear down the loopback callback + * listener the URL points at (staling the link). Instead, `connect` spawns + * this helper detached: the helper owns the whole interactive flow + * (callback listener, authorization-code exchange, token persistence to the + * shared `oauth.json`), reports the freshly minted authorize URL back over + * its stdout pipe, and keeps running after the parent exits — bounded by the + * flow's own 15-minute callback wait. The parent relays the URL and exits; + * the daemon-side pending entry completes on first use once tokens land. + * + * Params travel over **stdin as JSON**, never argv: `serverConfig` may carry + * header secrets, and argv is world-visible in `ps`. + */ + +/** Hidden subcommand name (see registerAuthCommands in mcp.ts). */ +export const AUTH_HELPER_COMMAND = "auth/complete-signin"; + +/** Params the parent writes to the helper's stdin as one JSON document. */ +export type AuthHelperParams = { + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; +}; + +/** One NDJSON line on the helper's stdout. */ +type AuthHelperEvent = + | { event: "auth_url"; url: string } + | { event: "done" } + | { event: "error"; message: string }; + +/** + * Pending sign-in marker, one per server URL, in the daemon dir (0700). + * A repeat `connect` while a helper is still waiting must reprint the SAME + * URL rather than mint a second flow: the fixed loopback callback port makes + * a second listener fail, and a fresh PKCE state would stale the link the + * user is already holding. + */ +export type PendingAuthMarker = { + url: string; + pid: number; + /** Epoch ms; matches the flow's own callback-wait bound. */ + expiresAt: number; +}; + +/** Matches the interactive flow's 15-minute loopback callback wait. */ +const PENDING_AUTH_TTL_MS = 15 * 60 * 1000; + +/** Bound on the parent's wait for the helper to report the auth URL. */ +const AUTH_URL_WAIT_MS = 60 * 1000; + +/** Bound on the helper's wait for params on stdin (parent writes eagerly). */ +const HELPER_STDIN_TIMEOUT_MS = 30 * 1000; + +export function pendingAuthMarkerPath(serverUrl: string): string { + const hash = createHash("sha256") + .update(serverUrl) + .digest("hex") + .slice(0, 16); + return path.join(getDaemonDir(), `pending-auth-${hash}.json`); +} + +/** + * Read the marker for `serverUrl` if it is still live: unexpired AND its + * helper process is still running (a killed/crashed helper must not pin a + * dead URL for up to 15 minutes). Stale markers are removed best-effort. + */ +export function readLivePendingAuthMarker( + serverUrl: string, +): PendingAuthMarker | undefined { + const markerPath = pendingAuthMarkerPath(serverUrl); + let marker: PendingAuthMarker; + try { + const parsed = JSON.parse(fs.readFileSync(markerPath, "utf8")) as unknown; + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as PendingAuthMarker).url !== "string" || + typeof (parsed as PendingAuthMarker).pid !== "number" || + typeof (parsed as PendingAuthMarker).expiresAt !== "number" + ) { + throw new Error("malformed marker"); + } + marker = parsed as PendingAuthMarker; + } catch { + return undefined; + } + const live = + marker.expiresAt > Date.now() && + (() => { + try { + process.kill(marker.pid, 0); + return true; + } catch { + return false; + } + })(); + if (!live) { + fs.rmSync(markerPath, { force: true }); + return undefined; + } + return marker; +} + +function writePendingAuthMarker(markerPath: string, marker: PendingAuthMarker) { + // Recreate exclusively (same symlink hardening as the daemon log): an + // append/overwrite open would follow a planted symlink and only apply the + // 0600 mode on create. + fs.rmSync(markerPath, { force: true }); + fs.writeFileSync(markerPath, `${JSON.stringify(marker)}\n`, { + flag: "wx", + mode: 0o600, + }); +} + +/** Read the helper's stdin to EOF and parse the params document. */ +async function readHelperParams(): Promise { + const chunks: Buffer[] = []; + const body = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("timed out waiting for params on stdin")); + }, HELPER_STDIN_TIMEOUT_MS); + timer.unref(); + process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on("end", () => { + clearTimeout(timer); + resolve(Buffer.concat(chunks).toString("utf8")); + }); + process.stdin.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); + const parsed = JSON.parse(body) as AuthHelperParams; + if (typeof parsed !== "object" || parsed === null || !parsed.serverConfig) { + throw new Error("auth helper params must include serverConfig"); + } + return parsed; +} + +/** + * Entry point for the hidden helper subcommand. Runs the full interactive + * OAuth flow with a navigation that reports the authorize URL as an NDJSON + * event on stdout (instead of printing a prompt line) and never opens a + * browser — the parent (or the human it relayed the URL to) does that. + */ +export async function runAuthHelper(): Promise { + // The parent unrefs and exits once it has the URL; every later stdout + // write would EPIPE without this guard. + const emit = (event: AuthHelperEvent) => { + try { + process.stdout.write(`${JSON.stringify(event)}\n`); + } catch { + // Parent is gone; the flow itself is unaffected. + } + }; + process.stdout.on("error", () => {}); + + const params = await readHelperParams(); + const serverUrl = + "url" in params.serverConfig ? params.serverConfig.url : undefined; + let markerPath: string | undefined; + try { + await authorizeInFrontend(params.serverConfig, params.serverSettings, { + makeNavigation: (autoOpenControl) => + new CallbackNavigation(async (url) => { + // Mirror createCliOAuthNavigation's arming: SDK-internal auth() + // during the plain connect() attempt must not leak a URL the + // flow isn't listening for yet. + if (!autoOpenControl.armed) return; + if (serverUrl !== undefined) { + markerPath = pendingAuthMarkerPath(serverUrl); + writePendingAuthMarker(markerPath, { + url: url.href, + pid: process.pid, + expiresAt: Date.now() + PENDING_AUTH_TTL_MS, + }); + } + emit({ event: "auth_url", url: url.href }); + }), + }); + emit({ event: "done" }); + } catch (error) { + emit({ + event: "error", + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + if (markerPath !== undefined) { + fs.rmSync(markerPath, { force: true }); + } + } +} + +/** + * Non-TTY connect path: return the authorize URL for `serverConfig`, either + * from a still-live pending marker (helper already waiting — reuse its URL) + * or by spawning a fresh detached helper and reading the URL off its stdout. + * + * After this resolves the helper is unrefed and survives this process: it + * holds the loopback callback listener and completes the token exchange when + * the user finishes signing in. + */ +export async function obtainPendingAuthUrl( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, + options?: { helperArgv1?: string }, +): Promise { + const serverUrl = "url" in serverConfig ? serverConfig.url : undefined; + if (serverUrl !== undefined) { + const marker = readLivePendingAuthMarker(serverUrl); + if (marker !== undefined) return marker.url; + } + + /* v8 ignore next 6 -- argv[1] is always the mcpdo bin in production. */ + const script = options?.helperArgv1 ?? process.argv[1]; + if (!script) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Cannot locate the mcpdo entry script to spawn the sign-in helper.", + { code: "usage" }, + ); + } + const child = spawn(process.execPath, [script, AUTH_HELPER_COMMAND], { + detached: true, + stdio: ["pipe", "pipe", "ignore"], + env: process.env, + }); + child.stdin.on("error", () => {}); + child.stdin.write(JSON.stringify({ serverConfig, serverSettings })); + child.stdin.end(); + + try { + return await new Promise((resolve, reject) => { + let buffer = ""; + const fail = (message: string) => { + reject( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, { + code: "auth_required", + }), + ); + }; + const timer = setTimeout(() => { + fail( + "Timed out waiting for the sign-in helper to produce an authorization URL.", + ); + }, AUTH_URL_WAIT_MS); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + let event: AuthHelperEvent; + try { + event = JSON.parse(line) as AuthHelperEvent; + } catch { + continue; + } + if (event.event === "auth_url") { + clearTimeout(timer); + resolve(event.url); + return; + } + if (event.event === "error") { + clearTimeout(timer); + fail(`Sign-in helper failed: ${event.message}`); + return; + } + } + }); + child.on("exit", (code) => { + clearTimeout(timer); + fail( + `Sign-in helper exited (code ${String(code)}) before producing an authorization URL.`, + ); + }); + child.on("error", (error) => { + clearTimeout(timer); + fail(`Failed to spawn sign-in helper: ${error.message}`); + }); + }); + } finally { + // Release the helper: close our ends of its pipes and drop it from this + // process's ref graph so `connect` can exit while it keeps waiting. + child.stdout.destroy(); + child.unref(); + } +} diff --git a/clients/daemon-cli/src/connection/authorize.ts b/clients/daemon-cli/src/connection/authorize.ts index 4df7c44f2f..260bab3153 100644 --- a/clients/daemon-cli/src/connection/authorize.ts +++ b/clients/daemon-cli/src/connection/authorize.ts @@ -23,16 +23,28 @@ import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; +import type { CallbackNavigation } from "@inspector/core/auth/index.js"; +import type { CliOAuthAutoOpenControl } from "@inspector/cli/cli-oauth-navigation.js"; import { mcpdoEmaGuidance } from "./ema.js"; /** * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens * land in the shared `oauth.json` store, then the daemon can reconnect. + * + * `makeNavigation` overrides how the authorize URL is surfaced once the + * flow's interactive window arms it: the default prints the relay-worded + * prompt line; the detached auth helper injects a navigation that reports + * the raw URL over its stdout pipe instead (see auth-helper.ts). */ export async function authorizeInFrontend( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, - options?: { storedAuthOnly?: boolean }, + options?: { + storedAuthOnly?: boolean; + makeNavigation?: ( + autoOpenControl: CliOAuthAutoOpenControl, + ) => CallbackNavigation; + }, ): Promise { if (!isOAuthCapableServerConfig(serverConfig)) { return; @@ -59,14 +71,16 @@ export async function authorizeInFrontend( // stdin/stderr. Reword the printed line so an agent knows it must relay // the link to a human rather than treating "Please navigate to" as // addressed to itself. - navigation: createCliOAuthNavigation({ - autoOpenControl, - disableAutoOpen: options?.storedAuthOnly, - promptMessage: (hrefDisplay, tty) => - tty - ? `Please navigate to: ${hrefDisplay}` - : `The user needs to navigate to this link to authenticate: ${hrefDisplay}`, - }), + navigation: options?.makeNavigation + ? options.makeNavigation(autoOpenControl) + : createCliOAuthNavigation({ + autoOpenControl, + disableAutoOpen: options?.storedAuthOnly, + promptMessage: (hrefDisplay, tty) => + tty + ? `Please navigate to: ${hrefDisplay}` + : `The user needs to navigate to this link to authenticate: ${hrefDisplay}`, + }), redirectUrlProvider, }; diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 3f69e335be..a8c71cb1e7 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -82,7 +82,17 @@ export type ConnectionWriteKind = source?: { kind: "catalog" | "config"; path: string }; } | { kind: "connections/list"; connections: unknown[] } - | { kind: "connection"; connection: ConnectionInfo | JsonObject } + | { + kind: "connection"; + connection: ConnectionInfo | JsonObject; + /** + * Non-TTY pending sign-in (see auth-helper.ts): the authorize URL the + * caller must relay to a human. Rides the normal output payload — the + * error envelope redacts URL query strings, which would strip the + * client_id/PKCE/state this URL is made of. + */ + authUrl?: string; + } | { kind: "disconnect"; name: string } | { kind: "daemon/status"; status: JsonObject } | { kind: "daemon/stop"; result: JsonObject } @@ -186,7 +196,9 @@ function jsonPayload(payload: ConnectionWriteKind): unknown { case "connections/list": return { connections: payload.connections }; case "connection": - return payload.connection; + return payload.authUrl !== undefined + ? { ...(payload.connection as JsonObject), authUrl: payload.authUrl } + : payload.connection; case "disconnect": return { name: payload.name }; case "daemon/status": @@ -233,8 +245,23 @@ function humanPayload(payload: ConnectionWriteKind, style: Style): string { return formatServerShowHuman(payload.server, style, payload.source); case "connections/list": return formatConnectionsListHuman(payload.connections, style); - case "connection": - return formatConnectionInfoHuman(payload.connection as JsonObject, style); + case "connection": { + const info = formatConnectionInfoHuman( + payload.connection as JsonObject, + style, + ); + if (payload.authUrl === undefined) return info; + const name = String((payload.connection as JsonObject).name ?? ""); + return [ + info, + "", + "Sign-in required. The user needs to open this link in a browser to authenticate:", + ` ${style.link(payload.authUrl)}`, + style.dim( + `The connection completes automatically after sign-in — check with \`connections/show @${name}\`, or just run the next command.`, + ), + ].join("\n"); + } case "disconnect": return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`; case "daemon/status": { diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 077f360123..7264830bdc 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -604,7 +604,7 @@ export function formatConnectionsListHuman( ? style.dim(` [${String(s.protocolEra)}]`) : ""; lines.push( - `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}`, + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}${s.pendingAuth === true ? style.yellow(" (sign-in pending)") : ""}`, ); } if (connections.length === 0) lines.push(style.dim("(none — connect first)")); @@ -651,6 +651,13 @@ export function formatConnectionInfoHuman( } lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`); } + // Sign-in pending (non-TTY connect handed OAuth to the detached helper): + // the connection completes automatically on first use after sign-in. + if (connection.pendingAuth === true) { + lines.push( + `Sign-in: ${style.yellow("pending")} ${style.dim("(completes automatically after the user signs in)")}`, + ); + } // Live transport state (`connections/show` only). Dormant is informational: // the next op transparently re-dials with stored credentials. if (typeof connection.transport === "string") { diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 2e95d7a43a..28337d7beb 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -44,6 +44,12 @@ import { type MethodArgs, } from "@inspector/cli/handlers/method-types.js"; import { authorizeInFrontend } from "./authorize.js"; +import { + AUTH_HELPER_COMMAND, + obtainPendingAuthUrl, + runAuthHelper, +} from "./auth-helper.js"; +import { isCliAutoOpenForced } from "@inspector/cli/cli-oauth-navigation.js"; import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; import { assertJsonRoundTrips, @@ -586,6 +592,37 @@ function registerConnect(program: CommandType): void { if (opts.storedAuthOnly) { throw error; } + // Agent path: no TTY anywhere means the blocking interactive flow is + // hostile — the URL sits invisible in a buffered pipe and a timeout + // kill would tear down the callback listener the link points at. + // Hand the flow to a detached helper, register the connection as + // pending intent, and exit with the link so the caller can relay it. + // `MCP_AUTO_OPEN_ENABLED=true` (forced auto-open) keeps the blocking + // flow: that's an explicit unattended-automation opt-in. + const humanPresent = + process.stdin.isTTY === true || process.stderr.isTTY === true; + if (!humanPresent && !isCliAutoOpenForced()) { + const authUrl = await obtainPendingAuthUrl( + serverConfig, + serverSettings, + ); + // The dial re-attempt is cheap (it fails auth_required again) but + // makes the daemon register the pending entry, so + // `connections/show @name` polls sign-in state and the first real + // op completes the connection via revive. + const { socketPath: pendingSocketPath } = await ensureDaemon(); + const pending = await callDaemon( + "connect", + { ...connectParams, pendingOnAuthRequired: true }, + { socketPath: pendingSocketPath, timeoutMs: 0 }, + ); + await writeConnectionOutput(outOpts(opts), { + kind: "connection", + connection: pending, + ...(pending.pendingAuth === true && { authUrl }), + }); + return; + } await authorizeInFrontend(serverConfig, serverSettings, { storedAuthOnly: false, }); @@ -609,6 +646,15 @@ function registerConnect(program: CommandType): void { } function registerAuthCommands(program: CommandType): void { + // Internal detached sign-in helper for the non-TTY connect path (see + // auth-helper.ts). Hidden: params arrive as JSON on stdin, never argv. + program + .command(AUTH_HELPER_COMMAND, { hidden: true }) + .description("Internal: complete an OAuth sign-in (params JSON on stdin)") + .action(async () => { + await runAuthHelper(); + }); + program .command("auth/list") .description( diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index 1002fd3962..c5c5d14ff0 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -38,7 +38,11 @@ import { } from "@inspector/core/auth/index.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; -import type { ConnectionAuthInfo, ConnectionInfo } from "./protocol.js"; +import type { + ConnectionAuthInfo, + ConnectionInfo, + ConnectParams, +} from "./protocol.js"; const CONNECTION_CLIENT_NAME = "inspector-cli"; @@ -56,6 +60,12 @@ type LiveConnection = { serverSettings?: InspectorServerSettings; /** Connect-time snapshot (see {@link ConnectionInfo.auth}). */ auth?: ConnectionAuthInfo; + /** + * Auth-pending intent entry (see {@link ConnectionInfo.pendingAuth}): + * registered with a never-connected client while the detached auth helper + * completes sign-in out of band. Cleared by the first successful revive. + */ + pendingAuth?: boolean; }; /** @@ -145,6 +155,7 @@ export class ConnectionRegistry { isMru: s.name === this.mruName, protocolEra: s.client.getProtocolEra(), ...(s.auth && { auth: s.auth }), + ...(s.pendingAuth && { pendingAuth: true }), })) .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); } @@ -295,6 +306,8 @@ export class ConnectionRegistry { } connection.client = client; connection.lastAccessedAt = Date.now(); + // A successful revive is the completion of any out-of-band sign-in. + delete connection.pendingAuth; // Refresh the auth snapshot `connections/list`/`use` report — the revive // may have rotated tokens. const auth = await getConnectionAuthInfo(client); @@ -363,16 +376,12 @@ export class ConnectionRegistry { isMru: true, protocolEra: connection.client.getProtocolEra(), ...(connection.auth && { auth: connection.auth }), + ...(connection.pendingAuth && { pendingAuth: true }), }; } async connect( - params: { - name: string; - serverConfig: MCPServerConfig; - serverSettings?: InspectorServerSettings; - serverIdentity: string; - }, + params: ConnectParams, signal?: AbortSignal, ): Promise { return this.withNameLock(params.name, () => @@ -381,12 +390,7 @@ export class ConnectionRegistry { } private async connectLocked( - params: { - name: string; - serverConfig: MCPServerConfig; - serverSettings?: InspectorServerSettings; - serverIdentity: string; - }, + params: ConnectParams, signal?: AbortSignal, ): Promise { this.assertOpen(); @@ -403,10 +407,36 @@ export class ConnectionRegistry { await this.disconnectLocked(params.name); } - const client = await this.dial(params, signal); + let client: InspectorClient; + let pendingAuth = false; + try { + client = await this.dial(params, signal); + } catch (error) { + if ( + !params.pendingOnAuthRequired || + !(error instanceof CliExitCodeError) || + error.envelope?.code !== "auth_required" + ) { + throw error; + } + // Sign-in is completing out of band (detached auth helper). + // Register the intent anyway with a never-connected client: its + // status is "disconnected" (terminal), so the first op after tokens + // land goes through liveClientFor → revive and dials with the fresh + // credentials — the entry self-completes on use. + client = await createConnectionClient( + params.serverConfig, + params.serverSettings, + ); + pendingAuth = true; + } const now = Date.now(); - const auth = await getConnectionAuthInfo(client); + // Pending entries snapshot as unauthorized OAuth: the whole point is + // that tokens aren't in storage yet. + const auth: ConnectionAuthInfo | undefined = pendingAuth + ? { method: "oauth", authorized: false } + : await getConnectionAuthInfo(client); if (this.closed) { // Shutdown proceeded past its bounded quiesce grace while this // connect was still in flight; the disconnectAll snapshot has already @@ -431,6 +461,7 @@ export class ConnectionRegistry { serverConfig: params.serverConfig, ...(params.serverSettings && { serverSettings: params.serverSettings }), ...(auth && { auth }), + ...(pendingAuth && { pendingAuth: true }), }); this.mruName = params.name; @@ -442,6 +473,7 @@ export class ConnectionRegistry { isMru: true, protocolEra: client.getProtocolEra(), ...(auth && { auth }), + ...(pendingAuth && { pendingAuth: true }), }; } finally { this.pendingConnects--; diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 10abca7f6a..5b89801013 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -32,6 +32,16 @@ export type ConnectParams = { serverSettings?: InspectorServerSettings; /** Human-readable server identity for `connections/list`. */ serverIdentity: string; + /** + * When true and the dial fails with `auth_required`, register the + * connection anyway as a dormant intent entry (never-connected client, + * terminal status) and return `ConnectionInfo` with `pendingAuth: true` + * instead of throwing. The front-end sets this on the non-TTY connect path + * after handing interactive OAuth to the detached auth helper: once the + * user finishes signing in, the next op on this connection revives it with + * the freshly stored credentials — no second `connect` required. + */ + pendingOnAuthRequired?: boolean; }; export type ConnectionNameParams = { @@ -175,6 +185,16 @@ export type ConnectionInfo = { * reports the persisted state, matching `auth/ema-status`. */ auth?: ConnectionAuthInfo; + /** + * True when this entry was registered as auth-pending intent + * ({@link ConnectParams.pendingOnAuthRequired}): the dial hit + * `auth_required` and interactive sign-in is completing out of band in the + * detached auth helper. The entry holds a never-connected client, so the + * first op after tokens land revives (dials) it transparently. Reported by + * `connect` and echoed by `connections/list`/`connections/show` until a + * revive succeeds. + */ + pendingAuth?: boolean; }; /** diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 4730e5c3ab..764fa6c654 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -427,6 +427,7 @@ export class DaemonServer { protocolVersion: client.getProtocolVersion(), protocolEra: client.getProtocolEra(), ...(auth && { auth }), + ...(connection.pendingAuth && { pendingAuth: true }), capabilities: client.getCapabilities(), instructions: client.getInstructions(), supportedVersions: client.getDiscoverResult()?.supportedVersions, From 8004c060ea1da241acd4ac50d0078fecda426756 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 16:31:19 -0700 Subject: [PATCH 44/69] mcpdo: park elicitations for non-interactive callers Non-TTY / --format json callers can no longer answer a mid-rpc elicitation prompt, so the daemon now parks the call at the first elicitation and returns an elicitationPending payload (exit 0) with the elicitation id, mode, message, form schema or URL, and expiry. A new 'elicitation/respond ' command answers it: form fields as key:=value pairs or one JSON object (accept), --done for URL-mode self-report, --decline (form only), or --cancel. Each respond returns the final rpc result or the next parked round, giving a stateless request/response loop that covers multi-round elicitation on both legacy and modern servers (task-augmented SEP-2663 flows already worked via tasks/* and are untouched). One parked call per connection: new rpcs are refused with an elicitation_pending error citing the respond command. Parked entries expire after 10 minutes; disconnect and daemon stop cancel them upstream so servers see a clean elicitation cancel. Interactive TTY sessions keep the existing inline prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/daemon-elicitation-park.test.ts | 554 ++++++++++++++++++ clients/daemon-cli/__tests__/dispatch.test.ts | 106 ++++ .../__tests__/format-connection.test.ts | 63 ++ .../__tests__/mcp-elicitation.test.ts | 187 ++++++ clients/daemon-cli/src/connection/dispatch.ts | 51 +- .../src/connection/format-connection.ts | 26 +- .../daemon-cli/src/connection/format-human.ts | 71 +++ clients/daemon-cli/src/connection/mcp.ts | 78 +++ .../daemon-cli/src/daemon/elicitation-park.ts | 228 +++++++ clients/daemon-cli/src/daemon/protocol.ts | 73 ++- clients/daemon-cli/src/daemon/server.ts | 294 +++++++++- 11 files changed, 1697 insertions(+), 34 deletions(-) create mode 100644 clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts create mode 100644 clients/daemon-cli/__tests__/mcp-elicitation.test.ts create mode 100644 clients/daemon-cli/src/daemon/elicitation-park.ts diff --git a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts new file mode 100644 index 0000000000..4231e78254 --- /dev/null +++ b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts @@ -0,0 +1,554 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { DaemonServer } from "../src/daemon/server.js"; +import { + ElicitationParkRegistry, + ParkingElicitationChannel, +} from "../src/daemon/elicitation-park.js"; +import type { + ElicitationPendingInfo, + ElicitationRespondResult, + RpcResult, +} from "../src/daemon/protocol.js"; +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +/** + * Covers daemon-side elicitation parking (dual-era support, phase 2): + * `rpc` with `parkElicitations` returning `elicitation-pending` instead of + * relaying an inline prompt, `elicitation/respond` resuming the parked call + * (final result, error, or the next round), expiry, the + * one-parked-call-per-connection guard, and the registry/channel primitives. + */ + +const runMethodMock = vi.hoisted(() => ({ + impl: undefined as unknown as (...args: unknown[]) => Promise, +})); +vi.mock("@inspector/cli/handlers/run-method.js", () => ({ + runMethod: (...args: unknown[]) => runMethodMock.impl(...args), +})); + +type FakeElicitationMessage = { + id: string; + origin: string; + request: { method: string; params: Record }; + respond: ReturnType; + cancel: ReturnType; +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const FORM_SCHEMA = { + type: "object", + properties: { color: { type: "string" } }, + required: ["color"], +}; + +function makeFormMessage(id: string): { + message: FakeElicitationMessage; + answered: Promise<{ action: string; content?: Record }>; + cancelled: Promise; +} { + const answer = deferred<{ + action: string; + content?: Record; + }>(); + const cancel = deferred(); + const message: FakeElicitationMessage = { + id, + origin: "server-request", + request: { + method: "elicitation/create", + params: { message: "Pick a color", requestedSchema: FORM_SCHEMA }, + }, + respond: vi.fn(async (response) => { + answer.resolve(response as never); + }), + cancel: vi.fn(() => { + cancel.resolve(); + answer.resolve({ action: "cancel" }); + }), + }; + return { message, answered: answer.promise, cancelled: cancel.promise }; +} + +function makeUrlMessage(id: string): { + message: FakeElicitationMessage; + answered: Promise<{ action: string; content?: Record }>; +} { + const answer = deferred<{ + action: string; + content?: Record; + }>(); + const message: FakeElicitationMessage = { + id, + origin: "server-request", + request: { + method: "elicitation/create", + params: { + message: "Finish signup", + url: "https://example.com/signup?flow=abc", + }, + }, + respond: vi.fn(async (response) => { + answer.resolve(response as never); + }), + cancel: vi.fn(() => answer.resolve({ action: "cancel" })), + }; + return { message, answered: answer.promise }; +} + +function fakeClient(): { client: InspectorClient; emit: (m: unknown) => void } { + const target = new EventTarget(); + const client = { + addEventListener: (type: string, listener: EventListener) => + target.addEventListener(type, listener), + removeEventListener: (type: string, listener: EventListener) => + target.removeEventListener(type, listener), + getStatus: () => "connected", + } as unknown as InspectorClient; + return { + client, + emit: (detail) => + target.dispatchEvent( + new CustomEvent("newPendingElicitation", { detail }), + ), + }; +} + +describe("daemon elicitation parking", () => { + let dir: string; + let server: DaemonServer; + let client: InspectorClient; + let emit: (m: unknown) => void; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-park-")); + server = new DaemonServer({ dir, idleMs: 0 }); + const fake = fakeClient(); + client = fake.client; + emit = fake.emit; + const registry = server.registry as unknown as Record; + registry.connectionFor = () => ({ name: "srv", client }); + registry.liveClientFor = async () => client; + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function rpcCallTool(id: string) { + return server.handle({ + id, + op: "rpc", + params: { + method: "tools/call", + toolName: "collect", + name: "srv", + parkElicitations: true, + }, + }); + } + + function respond( + id: string, + params: Record, + ): ReturnType { + return server.handle({ id, op: "elicitation/respond", params }); + } + + it("parks a form elicitation, then respond accept resumes to the final result", async () => { + const { message, answered } = makeFormMessage("elicit-1"); + runMethodMock.impl = async () => { + emit(message); + const answer = await answered; + return { + kind: "result", + result: { echoed: answer.content, action: answer.action }, + }; + }; + + const first = await rpcCallTool("r1"); + expect(first.ok).toBe(true); + const pending = (first as { result: RpcResult }).result; + expect(pending.kind).toBe("elicitation-pending"); + const info = (pending as { elicitation: ElicitationPendingInfo }) + .elicitation; + expect(info).toMatchObject({ + elicitationId: "elicit-1", + connection: "srv", + method: "tools/call", + toolName: "collect", + mode: "form", + message: "Pick a color", + requestedSchema: FORM_SCHEMA, + origin: "server-request", + }); + expect(info.expiresAt).toBeGreaterThan(Date.now()); + + const second = await respond("r2", { + elicitationId: "elicit-1", + action: "accept", + content: { color: "teal" }, + }); + expect(second.ok).toBe(true); + const result = (second as { result: ElicitationRespondResult }).result; + expect(result.method).toBe("tools/call"); + expect(result.toolName).toBe("collect"); + expect(result.outcome).toEqual({ + kind: "result", + result: { echoed: { color: "teal" }, action: "accept" }, + appInfo: undefined, + }); + expect(message.respond).toHaveBeenCalledWith({ + action: "accept", + content: { color: "teal" }, + }); + }); + + it("chains rounds: respond returns the next pending elicitation, then the result", async () => { + const round1 = makeFormMessage("elicit-a"); + const round2 = makeFormMessage("elicit-b"); + runMethodMock.impl = async () => { + emit(round1.message); + await round1.answered; + emit(round2.message); + const answer = await round2.answered; + return { kind: "result", result: { final: answer.content } }; + }; + + const first = await rpcCallTool("r1"); + expect((first as { result: RpcResult }).result.kind).toBe( + "elicitation-pending", + ); + + const mid = await respond("r2", { + elicitationId: "elicit-a", + action: "accept", + content: { color: "red" }, + }); + expect(mid.ok).toBe(true); + const midOutcome = (mid as { result: ElicitationRespondResult }).result + .outcome; + expect(midOutcome.kind).toBe("elicitation-pending"); + const nextId = (midOutcome as { elicitation: ElicitationPendingInfo }) + .elicitation.elicitationId; + expect(nextId).toBe("elicit-b"); + // The answered round's id is no longer respondable. + const stale = await respond("r3", { + elicitationId: "elicit-a", + action: "cancel", + }); + expect(stale.ok).toBe(false); + expect((stale as { error: { code: string } }).error.code).toBe( + "elicitation_not_found", + ); + + const done = await respond("r4", { + elicitationId: "elicit-b", + action: "accept", + content: { color: "blue" }, + }); + expect(done.ok).toBe(true); + expect( + (done as { result: ElicitationRespondResult }).result.outcome, + ).toMatchObject({ kind: "result", result: { final: { color: "blue" } } }); + }); + + it("relays decline and cancel; url mode accepts --done and rejects decline/content", async () => { + // decline (form) + const declineRound = makeFormMessage("elicit-d"); + runMethodMock.impl = async () => { + emit(declineRound.message); + const answer = await declineRound.answered; + return { kind: "result", result: { action: answer.action } }; + }; + await rpcCallTool("r1"); + const declined = await respond("r2", { + elicitationId: "elicit-d", + action: "decline", + }); + expect( + (declined as { result: ElicitationRespondResult }).result.outcome, + ).toMatchObject({ kind: "result", result: { action: "decline" } }); + expect(declineRound.message.respond).toHaveBeenCalledWith({ + action: "decline", + content: undefined, + }); + + // url mode + const urlRound = makeUrlMessage("elicit-u"); + runMethodMock.impl = async () => { + emit(urlRound.message); + const answer = await urlRound.answered; + return { kind: "result", result: { action: answer.action } }; + }; + const parked = await rpcCallTool("r3"); + const info = ( + (parked as { result: RpcResult }).result as { + elicitation: ElicitationPendingInfo; + } + ).elicitation; + expect(info.mode).toBe("url"); + expect(info.url).toBe("https://example.com/signup?flow=abc"); + + const badDecline = await respond("r4", { + elicitationId: "elicit-u", + action: "decline", + }); + expect(badDecline.ok).toBe(false); + expect((badDecline as { error: { code: string } }).error.code).toBe( + "invalid_params", + ); + const badContent = await respond("r5", { + elicitationId: "elicit-u", + action: "accept", + content: { nope: 1 }, + }); + expect(badContent.ok).toBe(false); + + // Validation failures put the entry back — a corrected accept still works. + const done = await respond("r6", { + elicitationId: "elicit-u", + action: "accept", + }); + expect(done.ok).toBe(true); + expect( + (done as { result: ElicitationRespondResult }).result.outcome, + ).toMatchObject({ kind: "result", result: { action: "accept" } }); + expect(urlRound.message.respond).toHaveBeenCalledWith({ + action: "accept", + content: undefined, + }); + }); + + it("returns the plain result when a parked-mode call never elicits, and propagates failures", async () => { + runMethodMock.impl = async () => ({ kind: "result", result: { n: 1 } }); + const plain = await rpcCallTool("r1"); + expect((plain as { result: RpcResult }).result).toMatchObject({ + kind: "result", + result: { n: 1 }, + }); + + runMethodMock.impl = async () => { + throw new Error("server exploded"); + }; + const failed = await rpcCallTool("r2"); + expect(failed.ok).toBe(false); + expect((failed as { error: { message: string } }).error.message).toContain( + "server exploded", + ); + }); + + it("propagates a failure that lands after the elicitation was answered", async () => { + const round = makeFormMessage("elicit-f"); + runMethodMock.impl = async () => { + emit(round.message); + await round.answered; + throw new Error("tool failed after input"); + }; + await rpcCallTool("r1"); + const failed = await respond("r2", { + elicitationId: "elicit-f", + action: "accept", + content: { color: "red" }, + }); + expect(failed.ok).toBe(false); + expect((failed as { error: { message: string } }).error.message).toContain( + "tool failed after input", + ); + }); + + it("rejects new rpcs on a connection with a parked call", async () => { + const round = makeFormMessage("elicit-g"); + runMethodMock.impl = async () => { + emit(round.message); + await round.answered; + return { kind: "result", result: {} }; + }; + await rpcCallTool("r1"); + const blocked = await server.handle({ + id: "r2", + op: "rpc", + params: { method: "tools/list", name: "srv" }, + }); + expect(blocked.ok).toBe(false); + expect((blocked as { error: { code: string } }).error.code).toBe( + "elicitation_pending", + ); + expect((blocked as { error: { message: string } }).error.message).toContain( + "elicitation/respond elicit-g", + ); + // Unblock: cancel it. + const cancelled = await respond("r3", { + elicitationId: "elicit-g", + action: "cancel", + }); + expect(cancelled.ok).toBe(true); + }); + + it("expires an unanswered parked elicitation and cancels the message", async () => { + server = new DaemonServer({ dir, idleMs: 0, elicitationTtlMs: 40 }); + const registry = server.registry as unknown as Record; + registry.connectionFor = () => ({ name: "srv", client }); + registry.liveClientFor = async () => client; + + const round = makeFormMessage("elicit-x"); + runMethodMock.impl = async () => { + emit(round.message); + await round.answered; + return { kind: "result", result: {} }; + }; + const parked = await rpcCallTool("r1"); + expect((parked as { result: RpcResult }).result.kind).toBe( + "elicitation-pending", + ); + await round.cancelled; + expect(round.message.cancel).toHaveBeenCalled(); + const late = await respond("r2", { + elicitationId: "elicit-x", + action: "accept", + content: { color: "red" }, + }); + expect(late.ok).toBe(false); + expect((late as { error: { code: string } }).error.code).toBe( + "elicitation_not_found", + ); + }); + + it("disconnect cancels the parked call; respond then reports not found", async () => { + const registry = server.registry as unknown as Record; + registry.disconnect = async () => ({ name: "srv" }); + + const round = makeFormMessage("elicit-z"); + runMethodMock.impl = async () => { + emit(round.message); + await round.answered; + return { kind: "result", result: {} }; + }; + await rpcCallTool("r1"); + const gone = await server.handle({ + id: "r2", + op: "disconnect", + params: { name: "srv" }, + }); + expect(gone.ok).toBe(true); + expect(round.message.cancel).toHaveBeenCalled(); + const late = await respond("r3", { + elicitationId: "elicit-z", + action: "cancel", + }); + expect(late.ok).toBe(false); + expect((late as { error: { code: string } }).error.code).toBe( + "elicitation_not_found", + ); + }); + + it("picks up a call that settled on its own while parked (server gave up waiting)", async () => { + const round = makeFormMessage("elicit-s"); + runMethodMock.impl = async () => { + emit(round.message); + // Server-side timeout: the call completes without our answer. + return { kind: "result", result: { timedOut: true } }; + }; + const parked = await rpcCallTool("r1"); + expect((parked as { result: RpcResult }).result.kind).toBe( + "elicitation-pending", + ); + const done = await respond("r2", { + elicitationId: "elicit-s", + action: "accept", + content: { color: "red" }, + }); + expect(done.ok).toBe(true); + expect( + (done as { result: ElicitationRespondResult }).result.outcome, + ).toMatchObject({ kind: "result", result: { timedOut: true } }); + }); + + it("validates respond params", async () => { + const missing = await respond("r1", { action: "accept" }); + expect((missing as { error: { code: string } }).error.code).toBe( + "invalid_params", + ); + const badAction = await respond("r2", { + elicitationId: "x", + action: "shrug", + }); + expect((badAction as { error: { code: string } }).error.code).toBe( + "invalid_params", + ); + const unknown = await respond("r3", { + elicitationId: "nope", + action: "cancel", + }); + expect((unknown as { error: { code: string } }).error.code).toBe( + "elicitation_not_found", + ); + }); +}); + +describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => { + const frame = (elicitationId: string) => + ({ + id: "req-1", + kind: "elicitation-request", + elicitationId, + mode: "form", + message: "hi", + origin: "server-request", + }) as const; + + it("answer() is a no-op with nothing pending; request after close rejects", async () => { + const channel = new ParkingElicitationChannel(); + channel.answer({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "none", + action: "cancel", + }); + channel.close(new Error("gone")); + await expect(channel.request(frame("later"))).rejects.toThrow("gone"); + }); + + it("close rejects a pending request and clears the waiter", async () => { + const channel = new ParkingElicitationChannel(); + const pending = channel.request(frame("e1")); + expect(channel.pendingFrame()?.elicitationId).toBe("e1"); + channel.close(new Error("teardown")); + await expect(pending).rejects.toThrow("teardown"); + expect(channel.pendingFrame()).toBeNull(); + }); + + it("cancelAll settles every parked entry", async () => { + const registry = new ElicitationParkRegistry(0); + const channel = new ParkingElicitationChannel(); + const pending = channel.request(frame("e1")); + registry.add({ + info: { + elicitationId: "e1", + connection: "srv", + method: "tools/call", + mode: "form", + message: "hi", + origin: "server-request", + }, + client: {} as InspectorClient, + channel, + outcome: new Promise(() => {}), + }); + registry.cancelAll(); + await expect(pending).rejects.toThrow(/going away/); + expect(() => registry.take("e1")).toThrow(/No pending elicitation/); + }); +}); diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts index 49eae65e98..23a294017e 100644 --- a/clients/daemon-cli/__tests__/dispatch.test.ts +++ b/clients/daemon-cli/__tests__/dispatch.test.ts @@ -335,6 +335,112 @@ describe("dispatchConnectionRpc", () => { expect.objectContaining({ interactive: false }), ); }); + + it("asks the daemon to park elicitations for --format json and for non-TTY text", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: undefined, + }); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: undefined, + }); + try { + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "tools/call", + {}, + { format: "text", requireExplicit: false }, + ); + await dispatchConnectionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + expect(callDaemon.mock.calls[0][1]).toMatchObject({ + parkElicitations: true, + }); + expect(callDaemon.mock.calls[1][1]).toMatchObject({ + parkElicitations: true, + }); + } finally { + if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); + if (stderrDesc) + Object.defineProperty(process.stderr, "isTTY", stderrDesc); + } + }); + + it("omits parkElicitations for interactive text (TTY)", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + try { + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "tools/call", + {}, + { format: "text", requireExplicit: false }, + ); + expect( + (callDaemon.mock.calls[0][1] as Record) + .parkElicitations, + ).toBeUndefined(); + } finally { + if (stderrDesc) + Object.defineProperty(process.stderr, "isTTY", stderrDesc); + } + }); + + it("renders an elicitation-pending outcome (json and human)", async () => { + const elicitation = { + elicitationId: "e-1", + connection: "srv", + method: "tools/call", + toolName: "collect", + mode: "form", + message: "Pick a color", + requestedSchema: { + type: "object", + properties: { color: { type: "string" } }, + required: ["color"], + }, + origin: "server-request", + expiresAt: Date.now() + 600_000, + }; + callDaemon.mockResolvedValue({ kind: "elicitation-pending", elicitation }); + const { dispatchConnectionRpc } = + await import("../src/connection/dispatch.js"); + await dispatchConnectionRpc( + "tools/call", + { toolName: "collect" }, + { format: "json", requireExplicit: false }, + ); + const parsed = JSON.parse(stdout) as { + elicitationPending: { elicitationId: string }; + }; + expect(parsed.elicitationPending.elicitationId).toBe("e-1"); + + stdout = ""; + await dispatchConnectionRpc( + "tools/call", + { toolName: "collect" }, + // --plain: human rendering must stay assertable when this test runs + // under a stderr TTY (styled output would interleave ANSI codes). + { format: "text", plain: true, requireExplicit: false }, + ); + expect(stdout).toContain("Input required"); + expect(stdout).toContain("Pick a color"); + expect(stdout).toContain("elicitation/respond e-1"); + expect(stdout).toContain("color (string, required)"); + }); }); describe("hoistAtConnection / stripAt / requireExplicitConnection", () => { diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index ea3357ddbd..ea3b980570 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -22,6 +22,7 @@ import { formatSkillVerifyListHuman, formatStreamEventHuman, formatRpcResultHuman, + formatElicitationPendingHuman, } from "../src/connection/format-human.js"; import { writeConnectionOutput } from "../src/connection/format-connection.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; @@ -574,6 +575,68 @@ describe("format-human", () => { }); }); +describe("formatElicitationPendingHuman", () => { + it("formatElicitationPendingHuman renders form fields and respond guidance", () => { + const text = formatElicitationPendingHuman({ + elicitationId: "e-9", + connection: "srv", + method: "tools/call", + toolName: "collect", + mode: "form", + message: "Pick a color", + requestedSchema: { + type: "object", + properties: { + color: { type: "string", description: "Favourite color" }, + size: { type: "string", enum: ["s", "m", "l"] }, + count: { type: "integer" }, + }, + required: ["color"], + }, + origin: "server-request", + expiresAt: Date.now() + 600_000, + }); + expect(text).toContain("Input required"); + expect(text).toContain("@srv"); + expect(text).toContain("Pick a color"); + expect(text).toContain("color (string, required)"); + expect(text).toContain("Favourite color"); + expect(text).toContain("size (enum) [s, m, l]"); + expect(text).toContain("count (integer)"); + expect(text).toContain("elicitation/respond e-9 field:=value"); + expect(text).toContain("--decline | --cancel"); + expect(text).toContain("expires"); + }); + + it("formatElicitationPendingHuman renders url mode with --done guidance; unsafe schemes stay plain", () => { + const info = { + elicitationId: "e-u", + connection: "srv", + method: "tools/call", + mode: "url", + message: "Finish signup", + url: "https://example.com/signup?flow=abc", + origin: "server-request", + expiresAt: 0, + }; + const styled = formatElicitationPendingHuman( + info, + createStyle({ color: true, links: true }), + ); + expect(styled).toContain("https://example.com/signup?flow=abc"); + expect(styled).toContain("\u001b]8;;https://example.com/signup?flow=abc"); + expect(styled).toContain("elicitation/respond e-u --done"); + expect(styled).toContain("elicitation/respond e-u --cancel"); + + const unsafe = formatElicitationPendingHuman( + { ...info, url: "file:///etc/passwd" }, + createStyle({ color: true, links: true }), + ); + expect(unsafe).toContain("file:///etc/passwd"); + expect(unsafe).not.toContain("\u001b]8"); + }); +}); + describe("writeConnectionOutput", () => { let stdout: string; let stderr: string; diff --git a/clients/daemon-cli/__tests__/mcp-elicitation.test.ts b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts new file mode 100644 index 0000000000..1c4416bf31 --- /dev/null +++ b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { callDaemon } from "../src/daemon/client.js"; +import type { ElicitationPendingInfo } from "../src/daemon/protocol.js"; + +/** + * End-to-end non-interactive elicitation: a real daemon, a real composable + * test server (stdio) whose `collect_elicitation` tool sends a legacy + * `elicitation/create` mid-call, a non-TTY front-end that gets the exchange + * parked (`elicitationPending`), and `elicitation/respond` resuming the call + * to its final result. + */ +describe("mcp non-interactive elicitation (e2e)", () => { + let storageDir: string | undefined; + let configPath: string | undefined; + let ttyDescriptors: Array<{ + stream: NodeJS.ReadStream | NodeJS.WriteStream; + desc: PropertyDescriptor | undefined; + }> = []; + + beforeAll(() => { + expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true); + }); + + afterEach(async () => { + for (const { stream, desc } of ttyDescriptors.splice(0)) { + if (desc) Object.defineProperty(stream, "isTTY", desc); + } + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + fs.rmSync(configPath, { force: true }); + configPath = undefined; + } + }); + + /** runMcp is in-process: force the non-TTY (parking) path regardless of + * how vitest itself was launched. */ + function stubNonTty(): void { + for (const stream of [process.stdin, process.stderr] as const) { + ttyDescriptors.push({ + stream, + desc: Object.getOwnPropertyDescriptor(stream, "isTTY"), + }); + Object.defineProperty(stream, "isTTY", { + configurable: true, + value: undefined, + }); + } + } + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-e2e-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_CONNECTION: "1", + }; + } + + function elicitServerArgs(): string[] { + const here = path.dirname(fileURLToPath(import.meta.url)); + const serverScript = path.resolve( + here, + "../../../test-servers/build/server-composable.js", + ); + expect(fs.existsSync(serverScript)).toBe(true); + configPath = path.join( + os.tmpdir(), + `elicit-server-${process.pid}-${Date.now()}.json`, + ); + fs.writeFileSync( + configPath, + JSON.stringify({ + serverInfo: { name: "elicit-e2e", version: "1.0.0" }, + tools: [{ preset: "collect_elicitation" }], + transport: { type: "stdio" }, + }), + ); + return ["node", serverScript, "--config", configPath]; + } + + it("parks a legacy form elicitation and elicitation/respond resumes to the tool result", async () => { + const e = env(); + stubNonTty(); + + const connected = await runMcp( + [ + "connect", + "--connection", + "el", + "--transport", + "stdio", + "--format", + "json", + "--", + ...elicitServerArgs(), + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + const parked = await runMcp( + [ + "tools/call", + "collect_elicitation", + "message:=Pick a color", + 'schema:={"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}', + "--format", + "json", + "--connection", + "el", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(parked); + const pending = JSON.parse(parked.stdout) as { + elicitationPending: ElicitationPendingInfo; + }; + expect(pending.elicitationPending).toMatchObject({ + connection: "el", + method: "tools/call", + toolName: "collect_elicitation", + mode: "form", + message: "Pick a color", + }); + const id = pending.elicitationPending.elicitationId; + expect(id).toBeTruthy(); + + // The connection refuses new rpcs while the call is parked. + const blocked = await runMcp( + ["tools/list", "--format", "json", "--connection", "el"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(blocked); + expect(blocked.output).toContain(`elicitation/respond ${id}`); + + const done = await runMcp( + ["elicitation/respond", id, "color:=teal", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(done); + expect(done.stdout).toContain("accept"); + expect(done.stdout).toContain("teal"); + }, 40000); + + it("validates flag exclusivity before contacting the daemon", async () => { + const e = env(); + stubNonTty(); + const conflicting = await runMcp( + ["elicitation/respond", "e-1", "--done", "--cancel"], + { env: e, timeout: 10000 }, + ); + expectCliFailure(conflicting); + expect(conflicting.output).toContain("exactly one of"); + + const empty = await runMcp(["elicitation/respond", "e-1"], { + env: e, + timeout: 10000, + }); + expectCliFailure(empty); + expect(empty.output).toContain("key:=value"); + }); +}); diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts index 9544b59437..3d21e76e4c 100644 --- a/clients/daemon-cli/src/connection/dispatch.ts +++ b/clients/daemon-cli/src/connection/dispatch.ts @@ -6,7 +6,7 @@ import type { } from "@inspector/cli/handlers/method-types.js"; import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; import { writeConnectionOutput } from "./format-connection.js"; -import { styleFromOpts } from "@inspector/cli/style.js"; +import { styleFromOpts, type Style } from "@inspector/cli/style.js"; import { promptElicitation } from "./elicitation-prompt.js"; const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); @@ -100,6 +100,14 @@ export async function dispatchConnectionRpc( const onSignal = () => ac.abort(); process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); + // Interactive callers get inline prompts; everyone else — `--format json` + // (single machine-readable payload) or no TTY at all (an agent's stdin is + // not wired to the human, so a prompt would hang until auto-cancel) — has + // the daemon park the elicitation and answers via `elicitation/respond`. + const interactive = + format === "text" && + (process.stdin.isTTY === true || process.stderr.isTTY === true); + if (!interactive) params.parkElicitations = true; let outcome: RpcResult; try { outcome = await callDaemon("rpc", params, { @@ -112,20 +120,43 @@ export async function dispatchConnectionRpc( promptElicitation(frame, { style, // Prompting only needs a readable stdin and a text-based reply - // channel, not an actual TTY — an agent relaying prompts to a human - // (or answering directly) over a plain pipe works the same way a - // human at a terminal does. `--format json` is still excluded since - // stdout is a single machine-readable payload there, not a place to - // interleave prompts. A stdin that's already closed (e.g. ` { + const { format, style } = out; + if (outcome.kind === "elicitation-pending") { + await writeConnectionOutput( + { format, style }, + { kind: "elicitation-pending", elicitation: outcome.elicitation }, + ); + return; + } if (outcome.kind === "ndjson") { await writeConnectionOutput( { format, style }, @@ -146,7 +177,7 @@ export async function dispatchConnectionRpc( method, result: outcome.result, appInfo: outcome.appInfo as CliAppInfo | undefined, - toolName: methodArgs.toolName, + toolName, }, ); } diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index a8c71cb1e7..259844238e 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -2,7 +2,10 @@ import { awaitableError, awaitableLog, } from "@inspector/cli/utils/awaitable-log.js"; -import type { ConnectionInfo } from "../daemon/protocol.js"; +import type { + ConnectionInfo, + ElicitationPendingInfo, +} from "../daemon/protocol.js"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; import type { CliAppInfo } from "@inspector/cli/handlers/method-types.js"; @@ -16,6 +19,7 @@ import { formatServerShowHuman, formatConnectionInfoHuman, formatConnectionsListHuman, + formatElicitationPendingHuman, formatSkillVerifyListHuman, formatStreamEventHuman, } from "./format-human.js"; @@ -93,6 +97,17 @@ export type ConnectionWriteKind = */ authUrl?: string; } + | { + /** + * A parked elicitation (non-interactive caller): everything needed to + * relay the request to a human and answer it with + * `elicitation/respond`. Rides the normal output payload for the same + * redaction reason as `authUrl` (URL-mode elicitations carry a URL + * whose query is meaningful). + */ + kind: "elicitation-pending"; + elicitation: ElicitationPendingInfo; + } | { kind: "disconnect"; name: string } | { kind: "daemon/status"; status: JsonObject } | { kind: "daemon/stop"; result: JsonObject } @@ -199,6 +214,10 @@ function jsonPayload(payload: ConnectionWriteKind): unknown { return payload.authUrl !== undefined ? { ...(payload.connection as JsonObject), authUrl: payload.authUrl } : payload.connection; + case "elicitation-pending": + // The key doubles as the discriminator: a caller can tell "input + // required" from a final tool result by `elicitationPending` alone. + return { elicitationPending: payload.elicitation }; case "disconnect": return { name: payload.name }; case "daemon/status": @@ -262,6 +281,11 @@ function humanPayload(payload: ConnectionWriteKind, style: Style): string { ), ].join("\n"); } + case "elicitation-pending": + return formatElicitationPendingHuman( + payload.elicitation as unknown as JsonObject, + style, + ); case "disconnect": return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`; case "daemon/status": { diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 7264830bdc..3411abfdb6 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -5,6 +5,7 @@ import { PLAIN, type Style } from "@inspector/cli/style.js"; import { isSafeLinkTarget } from "./sanitize.js"; +import { parseFormSchema } from "./form-schema.js"; type JsonObject = Record; @@ -591,6 +592,76 @@ export function formatServerShowHuman( } /** Format connections/list. */ +/** + * A parked elicitation (`kind: "elicitation-pending"`): show the caller — + * typically an agent relaying to a human — what the server is asking and + * exactly how to answer it. Guidance lives here in human output only; the + * JSON payload stays data-only (the mcpdo skill carries the procedure). + */ +export function formatElicitationPendingHuman( + elicitation: JsonObject, + style: Style = PLAIN, +): string { + const id = String(elicitation.elicitationId ?? ""); + const mode = elicitation.mode === "url" ? "url" : "form"; + const message = String(elicitation.message ?? ""); + const lines = [ + `${heading(style, "Input required")} — ${code(style, String(elicitation.method ?? ""))}${ + typeof elicitation.toolName === "string" + ? ` (tool ${code(style, elicitation.toolName)})` + : "" + } on ${code(style, `@${String(elicitation.connection ?? "")}`)} is waiting on the user:`, + ` ${message}`, + ]; + if (mode === "url") { + const url = typeof elicitation.url === "string" ? elicitation.url : ""; + lines.push( + "", + "The user needs to open this link and complete it:", + // Only allowlisted schemes render as a clickable OSC 8 link; a server + // supplying file:/custom-handler URLs gets plain text (see sanitize.ts). + ` ${isSafeLinkTarget(url) ? style.link(url, url) : url}`, + "", + `When they're done, run: ${code(style, `elicitation/respond ${id} --done`)}`, + style.dim(`To give up instead: elicitation/respond ${id} --cancel`), + ); + } else { + const fields = parseFormSchema( + elicitation.requestedSchema as Record | undefined, + ); + if (fields && fields.length > 0) { + lines.push("", heading(style, `Fields (${fields.length}):`)); + for (const field of fields) { + const kind = + field.kind === "number" && field.integer ? "integer" : field.kind; + const flags = field.required ? `${kind}, required` : kind; + const choices = + field.kind === "enum" || field.kind === "multiselect" + ? ` [${field.choices.map((c) => c.value).join(", ")}]` + : ""; + lines.push( + ` ${style.bold(field.name)} (${flags})${choices}${descSuffix(style, field.description)}`, + ); + } + } + lines.push( + "", + `Answer with: ${code(style, `elicitation/respond ${id} field:=value ...`)}`, + style.dim(`Or: elicitation/respond ${id} --decline | --cancel`), + ); + } + const expiresAt = Number(elicitation.expiresAt ?? 0); + if (Number.isFinite(expiresAt) && expiresAt > Date.now()) { + const minutes = Math.max(1, Math.round((expiresAt - Date.now()) / 60_000)); + lines.push( + style.dim( + `Unanswered, this expires (auto-cancels) in about ${minutes} minute${minutes === 1 ? "" : "s"}.`, + ), + ); + } + return lines.join("\n"); +} + export function formatConnectionsListHuman( connections: unknown[], style: Style = PLAIN, diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 28337d7beb..1e3daffc42 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -25,6 +25,8 @@ import { callDaemon, ensureDaemon } from "../daemon/index.js"; import type { ConnectionInfo, ConnectionShowResult, + ElicitationRespondParams, + ElicitationRespondResult, } from "../daemon/protocol.js"; import { annotateServerEntriesWithConnections, @@ -53,6 +55,7 @@ import { isCliAutoOpenForced } from "@inspector/cli/cli-oauth-navigation.js"; import { emaLogin, emaLogout, getEmaStatus } from "./ema.js"; import { assertJsonRoundTrips, + parseToolCallPositionals, resolveToolCallArgs, } from "./parse-tool-args.js"; import { resolveCommandPath } from "./resolve-command.js"; @@ -61,6 +64,7 @@ import { hoistAtConnection, requireExplicitConnection, stripAt, + writeRpcOutcome, } from "./dispatch.js"; import { writeConnectionOutput } from "./format-connection.js"; import { @@ -342,6 +346,7 @@ export async function runMcp(argv?: string[]): Promise { registerConnectionAdmin(program); registerAuthCommands(program); registerRpcCommands(program); + registerElicitationCommands(program); // Keep infra commands last in --help (just before Commander's built-in help). registerDaemonCommands(program); registerPrivateCommand(program); @@ -1214,6 +1219,79 @@ function registerRpcCommands(program: CommandType): void { } } +/** + * `elicitation/respond` — answers an elicitation the daemon parked for a + * non-interactive caller (`elicitationPending` output). One respond per + * round: the result is either the resumed call's final output or the next + * pending round. + */ +function registerElicitationCommands(program: CommandType): void { + program + .command("elicitation/respond") + .description( + "Answer a pending server elicitation (from elicitationPending output): form answers as key:=value pairs / JSON, --done for URL mode, or --decline / --cancel", + ) + .argument("", "Id from the elicitationPending payload") + .argument( + "[fields...]", + "Form answers as key:=value pairs or one JSON object (accepts)", + ) + .option( + "--done", + "URL mode: report the linked interaction as finished (accept)", + ) + .option("--decline", "Decline the request (form mode only)") + .option("--cancel", "Cancel the elicitation") + .action(async (elicitationId: string, fields: string[] | undefined, o) => { + const opts = program.opts(); + const flags = [ + o.done === true && "--done", + o.decline === true && "--decline", + o.cancel === true && "--cancel", + ].filter(Boolean) as string[]; + const hasFields = (fields?.length ?? 0) > 0; + if (flags.length > 1 || (flags.length === 1 && hasFields)) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Provide field values, or exactly one of --done / --decline / --cancel — not ${[...(hasFields ? ["field values"] : []), ...flags].join(" and ")}.`, + { code: "usage" }, + ); + } + if (flags.length === 0 && !hasFields) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Provide form answers as key:=value pairs (or one JSON object), or one of --done / --decline / --cancel.", + { code: "usage" }, + ); + } + const params: ElicitationRespondParams = o.cancel + ? { elicitationId, action: "cancel" } + : o.decline + ? { elicitationId, action: "decline" } + : hasFields + ? { + elicitationId, + action: "accept", + content: parseToolCallPositionals(fields!), + } + : { elicitationId, action: "accept" }; + const { socketPath } = await ensureDaemon(); + const result = await callDaemon( + "elicitation/respond", + params, + // The resumed call's duration is governed by MCP timeouts the + // daemon enforces; a fixed local deadline would falsely fail it. + { socketPath, timeoutMs: 0 }, + ); + await writeRpcOutcome( + outOpts(opts), + result.method, + result.toolName, + result.outcome, + ); + }); +} + async function runRpc( program: CommandType, method: string, diff --git a/clients/daemon-cli/src/daemon/elicitation-park.ts b/clients/daemon-cli/src/daemon/elicitation-park.ts new file mode 100644 index 0000000000..f3d309df15 --- /dev/null +++ b/clients/daemon-cli/src/daemon/elicitation-park.ts @@ -0,0 +1,228 @@ +/** + * Daemon-side parking for elicitations from non-interactive callers + * (dual-era support, phase 2). Instead of relaying an elicitation over the + * socket for an inline prompt — which a non-TTY agent can never answer, its + * stdin isn't wired to the human — the in-flight call is parked here: the + * originating `rpc` returns immediately with `kind: "elicitation-pending"`, + * and a later `elicitation/respond` op answers the exchange and picks up + * either the final call result or the next pending round. Works identically + * for both eras because the bridge funnels legacy server→client requests and + * modern non-task MRTR rounds through the same `ElicitationChannel` seam. + */ +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { ElicitationChannel } from "./ipc-glue.js"; +import type { + ElicitationPendingInfo, + ElicitationRequestFrame, + ElicitationResponseFrame, + RpcResult, +} from "./protocol.js"; + +/** + * How long an unanswered parked elicitation lives before the daemon cancels + * it. Long enough for an agent to relay a form to a human and collect + * answers; bounded so a caller that vanishes can't hold the server's + * elicitation request (and the parked call) open forever. + */ +export const PARKED_ELICITATION_TTL_MS = 10 * 60_000; + +/** + * {@link ElicitationChannel} that parks instead of prompting: `request()` + * returns a promise nobody answers until `elicitation/respond` calls + * {@link answer}. `waitForElicitation()` lets the rpc/respond handlers race + * the in-flight call against the next elicitation arriving. `close()` + * cancel-settles the current exchange and every future one (expiry or + * connection teardown) — the bridge's channel-failure path then `cancel()`s + * the underlying message, so the parked call always settles. + */ +export class ParkingElicitationChannel implements ElicitationChannel { + private pending: { + frame: ElicitationRequestFrame; + resolve: (frame: ElicitationResponseFrame) => void; + reject: (error: Error) => void; + } | null = null; + private waiter: ((frame: ElicitationRequestFrame) => void) | null = null; + private closed: Error | null = null; + + request(frame: ElicitationRequestFrame): Promise { + if (this.closed) return Promise.reject(this.closed); + return new Promise((resolve, reject) => { + this.pending = { frame, resolve, reject }; + if (this.waiter) { + const waiter = this.waiter; + this.waiter = null; + waiter(frame); + } + }); + } + + /** Resolves when the next elicitation arrives; never rejects. */ + waitForElicitation(): Promise { + if (this.pending) return Promise.resolve(this.pending.frame); + return new Promise((resolve) => { + this.waiter = resolve; + }); + } + + /** The frame of the exchange currently awaiting an answer, if any. */ + pendingFrame(): ElicitationRequestFrame | null { + return this.pending?.frame ?? null; + } + + /** + * Answer the pending exchange. A no-op when nothing is pending (the call + * settled on its own — e.g. the server timed out its elicitation and + * completed anyway); the caller then just picks up the settled outcome. + */ + answer(response: ElicitationResponseFrame): void { + const pending = this.pending; + this.pending = null; + pending?.resolve(response); + } + + /** Cancel-settle the pending exchange and auto-cancel all future ones. */ + close(error: Error): void { + this.closed = error; + const pending = this.pending; + this.pending = null; + this.waiter = null; + pending?.reject(error); + } +} + +export type ParkedCall = { + /** Current round's payload; re-pointed by {@link ElicitationParkRegistry.rearm}. */ + info: ElicitationPendingInfo; + /** Client the call runs on — guards against new rpcs interleaving. */ + client: InspectorClient; + channel: ParkingElicitationChannel; + /** Settles when the parked daemon-side call finishes (result or error). */ + outcome: Promise; + /** + * `awaiting` = parked, answerable; `responding` = an `elicitation/respond` + * is in flight for it (a concurrent respond must not double-answer). + */ + state: "awaiting" | "responding"; + timer: ReturnType | null; +}; + +/** + * All parked calls, keyed by the current round's `elicitationId`. At most + * one per connection: the daemon serializes rpcs per client and refuses new + * rpcs on a connection with a parked call (the bridge would misroute a + * second call's elicitations to the parked subscriber). + */ +export class ElicitationParkRegistry { + private readonly byId = new Map(); + private readonly ttlMs: number; + + constructor(ttlMs: number = PARKED_ELICITATION_TTL_MS) { + this.ttlMs = ttlMs; + } + + /** The parked call running on `client`, whatever its state, if any. */ + forClient(client: InspectorClient): ParkedCall | undefined { + for (const entry of this.byId.values()) { + if (entry.client === client) return entry; + } + return undefined; + } + + /** Park a call. `info.expiresAt` is set here from the registry's TTL. */ + add(entry: { + info: Omit; + client: InspectorClient; + channel: ParkingElicitationChannel; + outcome: Promise; + }): ParkedCall { + const parked: ParkedCall = { + ...entry, + info: { ...entry.info, expiresAt: Date.now() + this.ttlMs }, + state: "awaiting", + timer: null, + }; + this.byId.set(parked.info.elicitationId, parked); + this.armTimer(parked); + return parked; + } + + /** + * Claim a parked call for one `elicitation/respond`. Removes the id from + * the awaitable state so a concurrent respond can't double-answer. + */ + take(elicitationId: string): ParkedCall { + const entry = this.byId.get(elicitationId); + if (!entry || entry.state !== "awaiting") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `No pending elicitation '${elicitationId}' — it may have expired, been answered, or belong to a connection that closed.`, + { code: "elicitation_not_found" }, + ); + } + entry.state = "responding"; + this.clearTimer(entry); + return entry; + } + + /** Park the next round of an already-claimed call under a new id. */ + rearm(entry: ParkedCall, info: Omit) { + this.byId.delete(entry.info.elicitationId); + entry.info = { ...info, expiresAt: Date.now() + this.ttlMs }; + entry.state = "awaiting"; + this.byId.set(entry.info.elicitationId, entry); + this.armTimer(entry); + return entry.info; + } + + /** The parked call settled; forget it. */ + finish(entry: ParkedCall): void { + this.clearTimer(entry); + this.byId.delete(entry.info.elicitationId); + } + + /** Connection going away (disconnect / replacing connect): cancel its parked call. */ + cancelForConnection(connectionName: string): void { + for (const entry of this.byId.values()) { + if (entry.info.connection === connectionName) this.cancel(entry); + } + } + + /** Daemon shutdown: cancel everything so no server request is left held. */ + cancelAll(): void { + for (const entry of this.byId.values()) this.cancel(entry); + } + + private cancel(entry: ParkedCall): void { + this.finish(entry); + entry.channel.close( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Parked elicitation cancelled: the connection or daemon is going away.", + { code: "elicitation_cancelled" }, + ), + ); + } + + private armTimer(entry: ParkedCall): void { + if (this.ttlMs <= 0) return; + entry.timer = setTimeout(() => { + this.finish(entry); + entry.channel.close( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Parked elicitation expired unanswered.", + { code: "elicitation_expired" }, + ), + ); + }, this.ttlMs); + entry.timer.unref?.(); + } + + private clearTimer(entry: ParkedCall): void { + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + } + } +} diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 5b89801013..8abc44fcdd 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -24,7 +24,8 @@ export type DaemonOp = | "daemon/status" | "daemon/stop" | "rpc" - | "stream"; + | "stream" + | "elicitation/respond"; export type ConnectParams = { name: string; @@ -61,6 +62,17 @@ export type ConnectionNameParams = { export type RpcParams = ConnectionNameParams & MethodArgs & { method: string; + /** + * When true and the call surfaces a legacy or modern non-task MRTR + * elicitation, don't relay it over the socket for an inline prompt — + * park it daemon-side and return immediately with + * `kind: "elicitation-pending"`. The caller answers via the + * `elicitation/respond` op, whose result is either the final call + * outcome or the next pending round. Set by the front-end for + * non-interactive callers (`--format json`, non-TTY), which have no + * human at the stream to answer an inline prompt. + */ + parkElicitations?: boolean; }; export type DaemonRequest = { @@ -78,6 +90,7 @@ export type DaemonRequest = { | ConnectParams | ConnectionNameParams | RpcParams + | ElicitationRespondParams | Record; }; @@ -137,6 +150,54 @@ export type ElicitationResponseFrame = { content?: Record; }; +/** + * A parked elicitation, as reported to a non-interactive caller + * ({@link RpcParams.parkElicitations}): everything an agent needs to relay + * the request to a human and answer it with `elicitation/respond`. Rides the + * normal success payload — like the pending-auth URL, an elicitation URL's + * query string is meaningful data the error envelope would redact. + */ +export type ElicitationPendingInfo = { + /** Key for `elicitation/respond`; changes on every round. */ + elicitationId: string; + /** Connection whose in-flight call is parked. */ + connection: string; + /** Originating rpc method (e.g. `tools/call`), for output rendering. */ + method: string; + /** Originating tool, when the method was `tools/call`. */ + toolName?: string; + mode: "form" | "url"; + message: string; + /** Form mode only. */ + requestedSchema?: Record; + /** URL mode only. */ + url?: string; + /** Legacy server→client request vs. modern non-task MRTR round. */ + origin: PendingRequestOrigin; + /** Epoch ms; the exchange is auto-cancelled if unanswered by then. */ + expiresAt: number; +}; + +/** Params for the `elicitation/respond` op. */ +export type ElicitationRespondParams = { + elicitationId: string; + action: "accept" | "decline" | "cancel"; + /** Form mode `action: "accept"` only. */ + content?: Record; +}; + +/** + * `elicitation/respond` result. `outcome` is either the parked call's final + * result — the response the original `rpc` would have produced — or the next + * `elicitation-pending` round; `method`/`toolName` echo the originating call + * so the front-end can render that result the same way `rpc` output is. + */ +export type ElicitationRespondResult = { + method: string; + toolName?: string; + outcome: RpcResult; +}; + /** * Slim connect-time snapshot of a connection's authorization, projected from the * core `OAuthConnectionState` (see {@link ConnectionInfo.auth}). Absent entirely @@ -250,4 +311,14 @@ export type RpcResult = summary?: string; /** Non-zero when the emitted report is itself a failure (`--verify`). */ exitCode?: number; + } + | { + /** + * The call surfaced an elicitation while + * {@link RpcParams.parkElicitations} was set: the call is parked + * daemon-side awaiting `elicitation/respond`, and this is everything + * the caller needs to answer it. + */ + kind: "elicitation-pending"; + elicitation: ElicitationPendingInfo; }; diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 764fa6c654..e73ab8fee4 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -14,6 +14,10 @@ import { type HandleOutcome, } from "./ipc-glue.js"; import { wireElicitationBridge } from "./elicitation-bridge.js"; +import { + ElicitationParkRegistry, + ParkingElicitationChannel, +} from "./elicitation-park.js"; import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import { isTerminalStatus } from "@inspector/core/mcp/types.js"; @@ -32,6 +36,10 @@ import type { DaemonRequest, DaemonResponse, DaemonStatus, + ElicitationPendingInfo, + ElicitationRequestFrame, + ElicitationRespondParams, + ElicitationRespondResult, RpcParams, RpcResult, ConnectionNameParams, @@ -76,6 +84,11 @@ export type DaemonServerOptions = { * must not hang `daemon stop`). Tests use a short value. */ flushTimeoutMs?: number; + /** + * TTL for parked elicitations (`RpcParams.parkElicitations`); defaults to + * {@link PARKED_ELICITATION_TTL_MS}. Tests use a short value. + */ + elicitationTtlMs?: number; }; /** @@ -109,6 +122,8 @@ export class DaemonServer { * the bridge would route a prompt to the wrong caller's terminal; running * at most one rpc per connection at a time makes the routing exact. */ private readonly rpcQueues = new WeakMap>(); + /** Parked elicitations for non-interactive callers (see elicitation-park.ts). */ + private readonly parks: ElicitationParkRegistry; constructor(options: DaemonServerOptions = {}) { this.dir = options.dir ?? getDaemonDir(); @@ -118,6 +133,7 @@ export class DaemonServer { this.flushTimeoutMs = options.flushTimeoutMs ?? DaemonServer.FLUSH_TIMEOUT_MS; this.registry = new ConnectionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); + this.parks = new ElicitationParkRegistry(options.elicitationTtlMs); this.onShutdown = options.onShutdown ?? null; this.registry.setIdleHandler(() => { void this.stop("idle"); @@ -198,6 +214,9 @@ export class DaemonServer { private async doStop(reason: "idle" | "stop" | "signal"): Promise { void reason; this.stopping = true; + // Settle parked elicitations first: their held server requests must be + // cancelled before the connections under them are torn down. + this.parks.cancelAll(); // Quiesce: new ops are rejected above; wait (bounded — an rpc blocked on // an interactive elicitation prompt must not hang shutdown forever) for // in-flight ops so a concurrent connect lands in the registry before the @@ -359,6 +378,9 @@ export class DaemonServer { { code: "invalid_params" }, ); } + // A replacing connect tears down any previous connection under this + // name; a call parked on it can never be answered — settle it now. + this.parks.cancelForConnection(params.name); return { response: { id: request.id, @@ -369,15 +391,14 @@ export class DaemonServer { } case "disconnect": { const params = (request.params ?? {}) as ConnectionNameParams; + const result = await this.registry.disconnect( + params.name, + params.requireExplicit, + ); + // The connection is gone; a call parked on it can never be answered. + this.parks.cancelForConnection(result.name); return { - response: { - id: request.id, - ok: true, - result: await this.registry.disconnect( - params.name, - params.requireExplicit, - ), - }, + response: { id: request.id, ok: true, result }, }; } case "connections/list": @@ -466,6 +487,16 @@ export class DaemonServer { }; case "stream": return this.openStream(request.id, request.params as RpcParams); + case "elicitation/respond": + return { + response: { + id: request.id, + ok: true, + result: await this.respondElicitation( + request.params as ElicitationRespondParams, + ), + }, + }; default: throw new CliExitCodeError( EXIT_CODES.USAGE, @@ -485,13 +516,21 @@ export class DaemonServer { code: "invalid_params", }); } + const park = params.parkElicitations === true; + // Parking needs the connection *name* for the pending payload and for + // teardown-keyed cancellation; resolve it before reviving the client. + const connectionName = park + ? this.registry.connectionFor(params.name, params.requireExplicit).name + : undefined; const client = await this.registry.liveClientFor( params.name, params.requireExplicit, ); const previous = this.rpcQueues.get(client) ?? Promise.resolve(); const run = previous.then(() => - this.runRpcOnClient(client, requestId, params, elicitation), + park + ? this.runRpcParked(client, connectionName!, requestId, params) + : this.runRpcOnClient(client, requestId, params, elicitation), ); // Keep the queue alive past failures; each caller still sees its own // error through `run`. @@ -512,6 +551,7 @@ export class DaemonServer { elicitation: ElicitationChannel, ): Promise { const methodArgs = stripConnectionFields(params); + this.assertNoParkedCall(client); // Backstop against the silent-empty class: `runMethod`'s list states // return `[]` without error when the client isn't connected, which would // render as "Tools (0)" for a connection that actually dropped. The @@ -532,25 +572,159 @@ export class DaemonServer { } finally { unwire(); } - if (outcome.kind === "stream") { + return toRpcResult(outcome, params.method); + } + + /** + * A connection with a parked call must not accept new rpcs: the bridge + * routes elicitations to its oldest subscriber, so a second in-flight call + * would have its elicitations misdelivered to the parked exchange. + */ + private assertNoParkedCall(client: InspectorClient): void { + const parked = this.parks.forClient(client); + if (parked) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `A server elicitation is pending on this connection; answer it first: elicitation/respond ${parked.info.elicitationId} (or --cancel).`, + { code: "elicitation_pending" }, + ); + } + } + + /** + * `rpc` with `parkElicitations`: run the call racing its completion + * against the first elicitation. Completion first → ordinary result. + * Elicitation first → park the still-running call and return + * `elicitation-pending`; `elicitation/respond` picks it up from there. + */ + private async runRpcParked( + client: InspectorClient, + connectionName: string, + requestId: string, + params: RpcParams, + ): Promise { + const methodArgs = stripConnectionFields(params); + this.assertNoParkedCall(client); + if (isTerminalStatus(client.getStatus())) { + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "The connection dropped before this command could run; re-run the command to reconnect.", + { code: "connection_stale" }, + ); + } + const channel = new ParkingElicitationChannel(); + const unwire = wireElicitationBridge(client, channel, requestId); + const outcome: Promise = (async () => { + try { + return toRpcResult(await runMethod(client, methodArgs), params.method); + } finally { + unwire(); + } + })(); + const first = await raceCallOrElicitation(outcome, channel); + if (first.kind === "settled") return first.result; + if (first.kind === "failed") throw first.error; + // Parked: the call keeps running with nothing here awaiting it — the + // eventual settle is picked up by elicitation/respond, or discarded on + // expiry/teardown. The swallow keeps a discarded failure from becoming + // an unhandled rejection. + outcome.catch(() => {}); + const entry = this.parks.add({ + client, + channel, + outcome, + info: pendingInfo(first.frame, connectionName, { + method: params.method, + toolName: params.toolName, + }), + }); + return { kind: "elicitation-pending", elicitation: entry.info }; + } + + /** + * Answer a parked elicitation and pick up what the resumed call does + * next: its final result, its failure, or another elicitation round + * (re-parked under a fresh id). + */ + private async respondElicitation( + params: ElicitationRespondParams, + ): Promise { + if (!params?.elicitationId || typeof params.elicitationId !== "string") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "elicitation/respond requires an elicitationId", + { code: "invalid_params" }, + ); + } + const action = params.action; + if (action !== "accept" && action !== "decline" && action !== "cancel") { throw new CliExitCodeError( EXIT_CODES.USAGE, - `Method '${params.method}' is a stream; use the stream op.`, - { code: "use_stream_op" }, + "elicitation/respond action must be accept, decline, or cancel", + { code: "invalid_params" }, ); } - if (outcome.kind === "ndjson") { + const entry = this.parks.take(params.elicitationId); + try { + if (entry.info.mode === "url") { + if (action === "decline") { + // Mirrors the interactive prompt: URL mode has no decline — the + // user either reports completion (--done) or cancels. + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "A URL elicitation can't be declined — use --done once the linked interaction is finished, or --cancel.", + { code: "invalid_params" }, + ); + } + if (params.content !== undefined) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "A URL elicitation takes no field values — use --done once the linked interaction is finished.", + { code: "invalid_params" }, + ); + } + } + if (params.content !== undefined && action !== "accept") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Field values are only valid when accepting (omit --decline/--cancel).", + { code: "invalid_params" }, + ); + } + } catch (error) { + // Validation failed after the claim — put the entry back so a + // corrected respond can still answer it. + this.parks.rearm(entry, entry.info); + throw error; + } + entry.channel.answer({ + id: entry.channel.pendingFrame()?.id ?? "", + kind: "elicitation-response", + elicitationId: entry.info.elicitationId, + action, + ...(action === "accept" && entry.info.mode === "form" + ? { content: params.content ?? {} } + : {}), + }); + const { method, toolName, connection } = entry.info; + const next = await raceCallOrElicitation(entry.outcome, entry.channel); + if (next.kind === "elicited") { + const info = this.parks.rearm( + entry, + pendingInfo(next.frame, connection, { method, toolName }), + ); return { - kind: "ndjson", - lines: outcome.lines, - summary: outcome.summary, - exitCode: outcome.exitCode, + method, + ...(toolName !== undefined && { toolName }), + outcome: { kind: "elicitation-pending", elicitation: info }, }; } + this.parks.finish(entry); + if (next.kind === "failed") throw next.error; return { - kind: "result", - result: outcome.result, - appInfo: outcome.appInfo, + method, + ...(toolName !== undefined && { toolName }), + outcome: next.result, }; } @@ -732,10 +906,86 @@ function stripConnectionFields( ): MethodArgs & { method: string } { // `format` is a frontend-only output concern; forwarding it would make // runMethod's `format === "json"` branch collect app info (an extra - // resources/read) whose result the frontend discards. - const { name, requireExplicit, format, method, ...rest } = params; + // resources/read) whose result the frontend discards. `parkElicitations` + // is daemon routing, not a method argument. + const { name, requireExplicit, format, parkElicitations, method, ...rest } = + params; void name; void requireExplicit; void format; + void parkElicitations; return { method, ...rest }; } + +/** Convert a `runMethod` outcome into the serializable `rpc` result. */ +function toRpcResult( + outcome: Awaited>, + method: string, +): RpcResult { + if (outcome.kind === "stream") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Method '${method}' is a stream; use the stream op.`, + { code: "use_stream_op" }, + ); + } + if (outcome.kind === "ndjson") { + return { + kind: "ndjson", + lines: outcome.lines, + summary: outcome.summary, + exitCode: outcome.exitCode, + }; + } + return { + kind: "result", + result: outcome.result, + appInfo: outcome.appInfo, + }; +} + +/** Project one elicitation frame into the caller-facing pending payload. */ +function pendingInfo( + frame: ElicitationRequestFrame, + connectionName: string, + call: { method: string; toolName?: string }, +): Omit { + return { + elicitationId: frame.elicitationId, + connection: connectionName, + method: call.method, + ...(call.toolName !== undefined && { toolName: call.toolName }), + mode: frame.mode, + message: frame.message, + ...(frame.requestedSchema !== undefined && { + requestedSchema: frame.requestedSchema, + }), + ...(frame.url !== undefined && { url: frame.url }), + origin: frame.origin, + }; +} + +type CallOrElicitation = + | { kind: "settled"; result: RpcResult } + | { kind: "failed"; error: unknown } + | { kind: "elicited"; frame: ElicitationRequestFrame }; + +/** + * Race a (possibly parked) call's completion against its next elicitation. + * Both parking sites — the original `rpc` and each `elicitation/respond` + * round — end in exactly this decision. + */ +function raceCallOrElicitation( + outcome: Promise, + channel: ParkingElicitationChannel, +): Promise { + return Promise.race([ + outcome.then( + (result): CallOrElicitation => ({ kind: "settled", result }), + (error): CallOrElicitation => ({ kind: "failed", error }), + ), + channel + .waitForElicitation() + .then((frame): CallOrElicitation => ({ kind: "elicited", frame })), + ]); +} From 907f22d1608fedf4896ad128d448495ff000b05e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 16:55:26 -0700 Subject: [PATCH 45/69] eval(mcpdo): headless OAuth behavior case with an auto-consent user simulator The secure-add behavior case composes a requireAuth+DCR streamable-http server and measures the full headless auth flow: non-TTY connect exits 0 with the sign-in link, and the agent is expected to relay it and finish the tools/call once access is granted. The harness plays the human: cases opting in with autoConsent get a watcher that polls the shim transcript for /oauth/authorize URLs and approves each once (GET consent page, POST approve, follow the redirect to the detached auth helper's loopback callback), after which the agent's next call revives the connection. Also: missed samples now dump a compact transcript (argv, exit, first 300 chars of each stream) as the failure diagnostic, and CASE_MATCH filters cases by prompt substring for one-case iteration (labeled a dev probe, not a measurement). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 37 +++++++ scripts/lib/mcpdo-eval-matchers.mjs | 3 + scripts/lib/mcpdo-eval-matchers.test.mjs | 10 ++ scripts/skill-eval-mcpdo.mjs | 120 ++++++++++++++++++++++- scripts/skill-eval-mcpdo.test.mjs | 75 ++++++++++++++ 5 files changed, 242 insertions(+), 3 deletions(-) diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json index a3f642b5f5..823545b9f4 100644 --- a/clients/daemon-cli/evals/evals.json +++ b/clients/daemon-cli/evals/evals.json @@ -68,5 +68,42 @@ "stdoutMatch": "5" } ] + }, + { + "kind": "behavior", + "autoConsent": true, + "prompt": "Connect to the secure-add MCP server and use it to add 2 and 3. It requires signing in — share the sign-in link with me if one comes up, and finish the job once access is granted.", + "servers": { + "secure-add": { + "serverInfo": { "name": "secure-add", "version": "1.0.0" }, + "tools": [{ "preset": "add" }], + "oauth": { + "enabled": true, + "mode": "combined", + "requireAuth": true, + "scopesSupported": ["mcp"], + "supportDCR": true + }, + "transport": { "type": "streamable-http" } + } + }, + "expectCalls": [ + { + "cmd": "connect", + "connection": "secure-add", + "exit": 0, + "stdoutMatch": "oauth/authorize" + }, + { + "cmd": "tools/call", + "connection": "secure-add", + "tool": "add", + "args": { + "a": 2, + "b": 3 + }, + "stdoutMatch": "5" + } + ] } ] diff --git a/scripts/lib/mcpdo-eval-matchers.mjs b/scripts/lib/mcpdo-eval-matchers.mjs index 766879d2c2..faa58331aa 100644 --- a/scripts/lib/mcpdo-eval-matchers.mjs +++ b/scripts/lib/mcpdo-eval-matchers.mjs @@ -494,6 +494,9 @@ export function validateBehaviorCase(c, i) { } } }); + if (c.autoConsent !== undefined && typeof c.autoConsent !== "boolean") { + errors.push(`behavior case ${i}: \`autoConsent\` must be a boolean`); + } errors.push(...validateCaseServers(c, i)); return errors; } diff --git a/scripts/lib/mcpdo-eval-matchers.test.mjs b/scripts/lib/mcpdo-eval-matchers.test.mjs index 2d44199eda..b985feed5b 100644 --- a/scripts/lib/mcpdo-eval-matchers.test.mjs +++ b/scripts/lib/mcpdo-eval-matchers.test.mjs @@ -280,6 +280,16 @@ test("validateBehaviorCase: catches typos, bad types, bad regex", () => { assert.ok(errs.some((e) => /must be an object/.test(e))); }); +test("validateBehaviorCase: autoConsent must be a boolean", () => { + const base = { prompt: "p", expectCalls: [{ cmd: "connect" }] }; + assert.deepEqual(validateBehaviorCase({ ...base, autoConsent: true }, 0), []); + assert.ok( + validateBehaviorCase({ ...base, autoConsent: "yes" }, 0).some((e) => + /`autoConsent` must be a boolean/.test(e), + ), + ); +}); + test("matchPhases: interleaved prompt/answer/result ordering", () => { const r = { argv: ["tools/call", "collect"], diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index bb7b4008ca..e3897ac350 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -80,6 +80,7 @@ import { AGENTS, formatReport, runPrompt } from "./skill-eval.mjs"; import { parseSkill, validateEvalCases } from "./lib/skill-manifest.mjs"; import { evalExpectCalls, + streamText, validateBehaviorCase, } from "./lib/mcpdo-eval-matchers.mjs"; @@ -136,6 +137,10 @@ const AGENT = process.env.AGENT ?? "claude"; const BEHAVIOR_RUNS = Number(process.env.BEHAVIOR_RUNS ?? RUNS); const BEHAVIOR_THRESHOLD = Number(process.env.BEHAVIOR_THRESHOLD ?? 0.5); const BEHAVIOR_TURNS = Number(process.env.BEHAVIOR_TURNS ?? 14); +// Substring filter over case prompts (both sections) for cheap iteration on +// one case: `CASE_MATCH="add 2 and 3" npm run skills:eval:mcpdo`. A filtered +// run is a dev probe, not a measurement — the summary says so when active. +const CASE_MATCH = process.env.CASE_MATCH ?? ""; /** * Build the sandbox project one sample set runs in. @@ -446,6 +451,90 @@ export function readTranscript(logPath) { }); } +/** + * Simulate the human side of a headless OAuth flow: visit the sign-in link + * the agent was handed and approve the consent page. + * + * Non-TTY `connect` against an auth-requiring server exits 0 with the + * authorize URL in its output while a detached helper holds the loopback + * callback. In a real session the agent relays that URL and a human clicks + * it; here the harness is the human. GET shows the composable AS's consent + * page; POSTing the same params approves it; following the redirect delivers + * code+state to the helper's loopback listener, which finishes the token + * exchange — after which the agent's next call revives the connection. + * + * @param {string} authUrl The full /oauth/authorize URL from the transcript. + */ +export async function clickConsent(authUrl) { + let res = await fetch(authUrl, { redirect: "manual" }); + if (res.status === 200) { + const u = new URL(authUrl); + res = await fetch(`${u.origin}${u.pathname}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(u.searchParams), + redirect: "manual", + }); + } + if (res.status !== 301 && res.status !== 302) { + throw new Error( + `consent POST: expected redirect, got ${res.status}: ${(await res.text()).slice(0, 200)}`, + ); + } + const location = res.headers.get("location"); + if (!location) throw new Error("consent redirect missing Location header"); + // The loopback callback owned by the detached auth helper. + const cb = await fetch(new URL(location, authUrl)); + await cb.text().catch(() => {}); +} + +const AUTHORIZE_URL_RE = + /https?:\/\/[^\s"'<>\\]+\/oauth\/authorize\?[^\s"'<>\\]*/g; + +/** + * Watch a behavior sample's transcript for authorize URLs and auto-approve + * each one once (`autoConsent` cases). Polling the transcript, not the live + * streams: the shim appends a record when an invocation exits, and non-TTY + * connect exits as soon as it prints the URL, so the link shows up while + * the agent is still mid-session. Click failures are logged, not thrown — + * the case then fails on its own matchers, with this as the diagnostic. + * + * @param {string} logPath The sample's transcript path. + * @returns {{ stop: () => void }} + */ +export function startConsentClicker(logPath) { + const clicked = new Set(); + let inFlight = false; + const timer = setInterval(() => { + if (inFlight) return; + const urls = new Set(); + for (const record of readTranscript(logPath)) { + for (const event of record.events ?? []) { + for (const url of event.data?.match?.(AUTHORIZE_URL_RE) ?? []) { + if (!clicked.has(url)) urls.add(url); + } + } + } + if (urls.size === 0) return; + for (const url of urls) clicked.add(url); + inFlight = true; + (async () => { + for (const url of urls) { + try { + await clickConsent(url); + } catch (err) { + console.error(` autoConsent: click failed — ${err.message}`); + } + } + })().finally(() => { + inFlight = false; + }); + }, 250); + return { + stop: () => clearInterval(timer), + }; +} + /** * Run one behavior sample: fresh sandbox + hermetic env, one agent session, * transcript scored against the case's `expectCalls`. @@ -459,6 +548,7 @@ async function runBehaviorSample(c) { sandbox, caseServers(c), ); + const clicker = c.autoConsent === true ? startConsentClicker(logPath) : null; try { await runPrompt(c.prompt, { cwd: sandbox, @@ -469,8 +559,19 @@ async function runBehaviorSample(c) { }); const records = readTranscript(logPath); const { ok, failures } = evalExpectCalls(c.expectCalls, records); - return { hit: ok, failures, calls: records.length }; + // Compact transcript for miss diagnostics: what the agent actually ran + // and what it got back — the eval's equivalent of a stack trace. + const transcript = ok + ? [] + : records.map((r) => ({ + argv: r.argv, + exit: r.exit, + out: streamText(r, "stdout").slice(0, 300), + err: streamText(r, "stderr").slice(0, 300), + })); + return { hit: ok, failures, calls: records.length, transcript }; } finally { + clicker?.stop(); await teardown(); rmSync(sandbox, { recursive: true, force: true }); } @@ -509,11 +610,17 @@ async function main() { } } const { trigger, behavior } = loadCases(); + const byMatch = (c) => c.prompt.includes(CASE_MATCH); + if (CASE_MATCH !== "") { + console.log( + `skills:eval:mcpdo — CASE_MATCH filter active (${JSON.stringify(CASE_MATCH)}); this is a dev probe, not a measurement`, + ); + } let failed = 0; failed += await runTriggerSection( - trigger.map((c) => ({ ...c, from: SKILL_NAME })), + trigger.filter(byMatch).map((c) => ({ ...c, from: SKILL_NAME })), ); - failed += await runBehaviorSection(behavior); + failed += await runBehaviorSection(behavior.filter(byMatch)); process.exit(failed > 0 ? 1 : 0); } @@ -606,6 +713,13 @@ async function runBehaviorSection(cases) { console.log( ` miss (${r.calls} mcpdo calls): ${r.failures.join("; ")}`, ); + for (const t of r.transcript ?? []) { + console.log( + ` $ mcpdo ${t.argv.join(" ")} -> ${t.exit}` + + (t.out ? `\n out: ${t.out.replace(/\n/g, "\\n")}` : "") + + (t.err ? `\n err: ${t.err.replace(/\n/g, "\\n")}` : ""), + ); + } } } } diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs index f388ed8fd7..85d2df60a9 100644 --- a/scripts/skill-eval-mcpdo.test.mjs +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -19,8 +19,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { caseServers, + clickConsent, makeBehaviorEnv, readTranscript, + startConsentClicker, loadCases, } from "./skill-eval-mcpdo.mjs"; @@ -303,6 +305,79 @@ test("readTranscript: missing file and torn tail line", () => { } }); +test("consent clicker: approves each authorize URL from the transcript once", async () => { + const { createServer } = await import("node:http"); + const hits = { get: 0, post: 0, callback: 0 }; + const server = createServer((req, res) => { + const u = new URL(req.url, "http://127.0.0.1"); + if (u.pathname === "/oauth/authorize" && req.method === "GET") { + hits.get++; + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("
consent
"); + } else if (u.pathname === "/oauth/authorize" && req.method === "POST") { + hits.post++; + res.writeHead(302, { + Location: `http://127.0.0.1:${server.address().port}/cb?code=x&state=s`, + }); + res.end(); + } else if (u.pathname === "/cb") { + hits.callback++; + res.writeHead(200); + res.end("done"); + } else { + res.writeHead(404); + res.end(); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + const authUrl = `http://127.0.0.1:${port}/oauth/authorize?client_id=c&state=s`; + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); + const logPath = path.join(dir, "log.ndjson"); + const record = JSON.stringify({ + argv: ["connect", "secure"], + exit: 0, + events: [{ t: 1, stream: "stdout", data: `"authUrl": "${authUrl}"` }], + }); + writeFileSync(logPath, `${record}\n`); + const clicker = startConsentClicker(logPath); + try { + const deadline = Date.now() + 5000; + while (hits.callback === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + assert.equal(hits.get, 1); + assert.equal(hits.post, 1); + assert.equal(hits.callback, 1); + // Same URL appearing again (an agent re-printing it) is not re-clicked. + writeFileSync(logPath, `${record}\n${record}\n`); + await new Promise((r) => setTimeout(r, 700)); + assert.equal(hits.post, 1); + } finally { + clicker.stop(); + server.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("clickConsent: throws when the AS does not redirect", async () => { + const { createServer } = await import("node:http"); + const server = createServer((_req, res) => { + res.writeHead(400); + res.end("nope"); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + try { + await assert.rejects( + clickConsent(`http://127.0.0.1:${port}/oauth/authorize?x=1`), + /expected redirect, got 400/, + ); + } finally { + server.close(); + } +}); + test("loadCases: the committed evals file validates and partitions", () => { const { trigger, behavior } = loadCases(); assert.ok(trigger.length >= 5); From edbd546bd16a37dd52f60b318dddff2fc03505c7 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 17:14:02 -0700 Subject: [PATCH 46/69] eval(mcpdo): elicitation behavior case with an intrinsic-elicitation fixture tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_elicitation can only exercise the elicitation wire: the caller composes the message and schema, so an agent can trivially satisfy it. Measuring elicitation BEHAVIOR needs a tool whose elicitation is intrinsic — the elicited fields deliberately absent from the input schema, so the only way to a result is answering the mid-call request. New submit_ticket preset: takes only a summary, elicits contact name/email over legacy elicitation/create, and returns a ticket id derived from the accepted content (decline/cancel: ticket not filed). The helpdesk eval case gives the agent the contact facts in a natural prompt and asserts the parked tools/call (exit 0, elicitationPending) followed by an elicitation/respond returning the ticket number. No harness user-simulator involved — the agent itself is the answerer. First probe (claude, 3 runs): 3/3 with the current SKILL.md — the pending output's in-band answer guidance is sufficient. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 24 +++++++++ test-servers/src/preset-registry.ts | 3 ++ test-servers/src/test-server-fixtures.ts | 67 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json index 823545b9f4..bbc005bb58 100644 --- a/clients/daemon-cli/evals/evals.json +++ b/clients/daemon-cli/evals/evals.json @@ -105,5 +105,29 @@ "stdoutMatch": "5" } ] + }, + { + "kind": "behavior", + "prompt": "File a ticket on the helpdesk server with the summary 'Printer on floor 3 is jammed'. If it needs contact details, I'm Ada Lovelace, ada@example.com. Tell me the ticket number.", + "servers": { + "helpdesk": { + "serverInfo": { "name": "helpdesk", "version": "1.0.0" }, + "tools": [{ "preset": "submit_ticket" }] + } + }, + "expectCalls": [ + { + "cmd": "tools/call", + "connection": "helpdesk", + "tool": "submit_ticket", + "exit": 0, + "stdoutMatch": "elicitationPending|Input required" + }, + { + "cmd": "elicitation/respond", + "exit": 0, + "stdoutMatch": "TCK-" + } + ] } ] diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index fe0f92f6ec..115ce0a909 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -24,6 +24,7 @@ import { createCollectSampleTool, createListRootsTool, createCollectFormElicitationTool, + createSubmitTicketTool, createMrtrTool, createMrtrMultiRoundTool, createMrtrRootsTool, @@ -149,6 +150,8 @@ function resolveToolPreset( return createListRootsTool(); case "collect_elicitation": return createCollectFormElicitationTool(); + case "submit_ticket": + return createSubmitTicketTool(); case "mrtr_confirm": return createMrtrTool(); case "mrtr_two_step": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 9db734abbb..e94cca05f5 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -494,6 +494,73 @@ export function createCollectFormElicitationTool(): ToolDefinition { }; } +/** + * A tool whose elicitation is INTRINSIC: the elicited fields are deliberately + * absent from the input schema, so a caller cannot pre-supply them as + * arguments — the only way to a ticket number is to answer the mid-call + * `elicitation/create`. That is what makes it a fixture for measuring + * elicitation *behavior* (does an agent recognize the request, map known + * facts onto the requested schema, and use the result?), where + * `collect_elicitation` — whose message/schema are caller-composed — can + * only exercise the wire. Legacy-only, like `collect_elicitation`: it calls + * `server.elicitInput`, which errors on the 2026-07-28 leg. + */ +export function createSubmitTicketTool(): ToolDefinition { + return { + name: "submit_ticket", + description: + "File a helpdesk ticket with the given summary and return the ticket number. Contact details are collected separately when the ticket is filed.", + inputSchema: { + summary: z.string().describe("One-line summary of the issue"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const summary = params.summary as string; + const result = await context.server.server.elicitInput({ + message: "Who should we contact about this ticket?", + requestedSchema: { + type: "object", + properties: { + contact_name: { + type: "string", + title: "Contact name", + description: "Full name of the person to contact", + }, + contact_email: { + type: "string", + title: "Contact email", + description: "Email address for updates on this ticket", + }, + }, + required: ["contact_name", "contact_email"], + }, + }); + if (result.action !== "accept") { + return toToolResult( + `Ticket not filed: contact details ${result.action === "decline" ? "declined" : "cancelled"}.`, + ); + } + const content = (result.content ?? {}) as Record; + // Deterministic-looking but content-derived id, so distinct submissions + // get distinct numbers without the fixture holding state. + const ticket = `TCK-${(1000 + ((summary.length * 37 + String(content.contact_email).length * 101) % 9000)).toString()}`; + return toToolResult( + JSON.stringify({ + ticket, + summary, + contact_name: content.contact_name, + contact_email: content.contact_email, + }), + ); + }, + }; +} + /** Canonical URI for {@link createAppElicitationResource}, referenced by {@link createAppElicitationTool}'s `_meta.ui.resourceUri`. */ export const APP_ELICITATION_URI = "ui://demo/choose-option.html"; From 71a2b52f4db70de5ccdbbd730c0727370ebba751 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 28 Sep 2026 17:43:24 -0700 Subject: [PATCH 47/69] mcpdo: batch-update SKILL.md and add skill-gaps regression eval cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md body rewrite (description frontmatter unchanged): - command summary opens with servers/list; canonical flow spelled out (servers/list -> connect -> @entry ) - new 'How to think about mcpdo' section: connections extend the toolset, answer capability questions with them, don't auto-connect, inspect via commands not the filesystem - new 'The catalog' section: writable ~/.mcp-inspector/mcp.json, --catalog / MCP_CATALOG_PATH, servers/* vs connections/*, edit by file - Conventions: connection self-healing and the [legacy]/[modern] era tag - 'Auth': non-TTY connect exits 0 with pendingAuth + authUrl; relay the link, then retry the command with short sleeps (measured: 'wait for the user' wording made agents end their turn — 0/3; retry wording 3/3) - 'Elicitations': parking + elicitation/respond, decline/cancel/--done, TTL and one-parked-call limit Four regression behavior cases appended to evals.json (each guards a fixed skill gap): connections-awareness, catalog-discovery, explicit-connection (two-server catalog, matcher pins the connection), helpdesk-decline (asserted via stdoutMatch since boolean flags don't surface in parsed argv). All baselined 3/3 pre-edit; post-edit full suite: trigger 8/8 at 100%, behavior 8/8 at 3/3 incl. secure-add 2/3 -> 3/3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 71 +++++++++++++++++++ skills/mcpdo/SKILL.md | 101 +++++++++++++++++++++------- 2 files changed, 147 insertions(+), 25 deletions(-) diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json index bbc005bb58..13c5b8e0b8 100644 --- a/clients/daemon-cli/evals/evals.json +++ b/clients/daemon-cli/evals/evals.json @@ -129,5 +129,76 @@ "stdoutMatch": "TCK-" } ] + }, + { + "kind": "behavior", + "prompt": "What MCP servers am I connected to right now?", + "expectCalls": [ + { + "cmd": "connections/list", + "exit": 0 + } + ] + }, + { + "kind": "behavior", + "prompt": "What MCP servers are available for me to connect to? Don't connect to anything, just tell me what's there.", + "expectCalls": [ + { + "cmd": "servers/list", + "exit": 0, + "stdoutMatch": "test-stdio" + } + ] + }, + { + "kind": "behavior", + "prompt": "My catalog has two MCP servers, alpha and beta. Use beta to compute the sum of 4 and 9.", + "servers": { + "alpha": { + "serverInfo": { "name": "alpha", "version": "1.0.0" }, + "tools": [{ "preset": "echo" }] + }, + "beta": { + "serverInfo": { "name": "beta", "version": "1.0.0" }, + "tools": [{ "preset": "get_sum" }] + } + }, + "expectCalls": [ + { + "cmd": "tools/call", + "connection": "beta", + "tool": "get_sum", + "args": { + "a": 4, + "b": 9 + }, + "stdoutMatch": "13" + } + ] + }, + { + "kind": "behavior", + "prompt": "File a ticket on the helpdesk server with the summary 'Broken keyboard'. If it asks for contact details, do NOT share any personal information — decline that request — and tell me what happened.", + "servers": { + "helpdesk": { + "serverInfo": { "name": "helpdesk", "version": "1.0.0" }, + "tools": [{ "preset": "submit_ticket" }] + } + }, + "expectCalls": [ + { + "cmd": "tools/call", + "connection": "helpdesk", + "tool": "submit_ticket", + "exit": 0, + "stdoutMatch": "elicitationPending|Input required" + }, + { + "cmd": "elicitation/respond", + "exit": 0, + "stdoutMatch": "not filed|declined" + } + ] } ] diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 87879c82b7..0ed538af7e 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -10,10 +10,12 @@ Connect to an MCP server once, then run many commands against that named connection. ```bash -mcpdo connect entry-name --config ./mcp.json # entry from a config file +mcpdo servers/list # catalog entries you can connect +mcpdo connect entry-name # connect a catalog entry mcpdo connect https://example.com/mcp # ad-hoc HTTP/SSE target mcpdo connect node server.js # ad-hoc stdio target +mcpdo connections/list # open connections mcpdo @entry-name tools/list mcpdo @entry-name tools/call arg:=value mcpdo @entry-name resources/list @@ -25,33 +27,82 @@ mcpdo --connection entry-name tools/list # flag form of @entry-name mcpdo disconnect entry-name ``` +The canonical flow is `servers/list` → `connect ` → `@entry `. Run `mcpdo help` or `mcpdo --help` for the full, authoritative list of -commands and flags. +commands and flags (auth management, elicitation controls, daemon control, and +more). + +## How to think about mcpdo + +- **Connections extend your toolset.** Treat every open connection's tools, + resources, and prompts as part of your available capabilities: when facing a + task (or before saying "I can't do that"), check `mcpdo connections/list` + and the connected servers' `tools/list`, and call those tools mid-task like + any built-in tool. +- **Answer capability questions with it.** "What MCP servers/tools do I have?" + includes mcpdo connections (`connections/list`) and catalog entries that are + connectable but not connected (`servers/list`). +- **Don't auto-connect.** Catalog entries don't describe capabilities, and + connecting can start an auth flow involving the human. Connect when the user + directs it, or when a task clearly needs it — and say so first. +- **Inspect via commands, not the filesystem.** Entries, connections, auth + state, and daemon state all have read commands; never list or read + `~/.mcp-inspector` directly. The one exception is _editing_ the catalog + (below). + +## The catalog + +- `servers/list` / `servers/show ` read the **catalog**: the writable + entry file at `~/.mcp-inspector/mcp.json` (standard `mcpServers` shape), + overridable per shell via `--catalog ` or `MCP_CATALOG_PATH`. + `servers/list` prints the resolved source path. +- `servers/*` shows entries on disk; `connections/*` shows live daemon state. + Two shells with different catalogs share the same connections. +- There are no CLI edit commands, by design: add or remove entries by editing + the catalog file directly. `mcpdo connect entry --config path/to/mcp.json` + instead connects an entry from a read-only foreign config file. ## Conventions - `--format json` outputs JSON; the default, `--format text`, is human-readable. -- `mcpdo connections/list` shows open connections; `@name` (prefix on any command) - or `--connection ` (shorthand `--conn`) selects one explicitly. Always - qualify commands this way from an agent shell: with non-interactive (non-TTY) - stdin, mcpdo requires an explicit connection and errors without one. Omitting - it falls back to the most-recently-used connection only on an interactive - TTY, or anywhere when `MCP_ALLOW_DEFAULT_CONNECTION=1` is set. -- A connected connection persists across separate `mcpdo` invocations — no need - to reconnect before each command. `mcpdo disconnect` ends one connection; - `mcpdo daemon stop` resets everything. -- `mcpdo connect --config path/to/mcp.json` connects a - pre-declared catalog entry (may include auth, headers, protocol-era - overrides); `mcpdo connect ` connects an ad-hoc target with - defaults. -- Auth is handled automatically at connect time and stored for reuse (`mcpdo -auth/list` / `mcpdo auth/clear`); nothing extra is needed for authenticated - HTTP servers beyond `connect` and completing the browser flow if prompted. -- If a server asks a question mid-call (elicitation), mcpdo prompts - interactively by default — including over a plain non-TTY stdin, so an - agent can relay the question and answer it. Only `--format json` (whose - stdout must stay a single machine-readable payload) auto-declines instead - of prompting. Pass `--elicit off` on - `connect` if you want a well-behaved server to fall back to its own - defaults instead. +- Always qualify commands with `@name` or `--connection ` (shorthand + `--conn`) from an agent shell: with non-interactive (non-TTY) stdin, mcpdo + requires an explicit connection and errors without one. Omitting it falls + back to the most-recently-used connection only on an interactive TTY, or + anywhere when `MCP_ALLOW_DEFAULT_CONNECTION=1` is set. +- Connections persist across separate `mcpdo` invocations and **self-heal**: a + dropped transport (expired session, exited stdio child) transparently + re-dials on next use with stored credentials. Don't monitor or reconnect + manually; only an `auth_required` error needs action (re-run `connect`). + `mcpdo disconnect` ends one connection; `mcpdo daemon stop` resets + everything. +- The `[legacy]` / `[modern]` era tag on `connections/list` is the negotiated + protocol generation (`legacy` = classic `initialize` handshake — current and + fine, not deprecated). Informational only. + +## Auth + +- Auth is automatic at connect time and stored for reuse (`mcpdo auth/list` / + `mcpdo auth/clear`). When a browser sign-in is needed and stdin is non-TTY, + `connect` exits 0 immediately with `pendingAuth: true` and an `authUrl`: + relay that URL to the user verbatim, then finish the job — the connection + completes automatically once they sign in, which often takes only moments. + Retry the intended command (sleep a few seconds between attempts) and only + hand back to the user if sign-in still hasn't completed after a few tries. + Never reconnect to fix a pending sign-in. + +## Elicitations (server asks a question mid-call) + +- On an interactive TTY, mcpdo prompts inline. From an agent shell (non-TTY or + `--format json`), the call instead **parks** and exits 0 with an + `elicitationPending` payload carrying the question, schema, and an + `elicitationId`. +- Answer with `mcpdo elicitation/respond field:=value ...` + (repeat if the server asks again), or end it with `--decline` or `--cancel`. + For URL-mode elicitations, relay the URL to the user, then confirm with + `elicitation/respond --done` (or `--decline`). The response returns the + final tool result. +- Parked calls expire after 10 minutes; one parked call per connection. Pass + `--elicit off` on `connect` to have well-behaved servers fall back to their + own defaults instead of asking. From d84825258f11dc15f19ce6c110364a5e76b13a7e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 08:27:32 -0700 Subject: [PATCH 48/69] mcpdo: reshape agent-help around the skill body - `agent-help` (default, or explicit `--skill`) now prints the SKILL.md body with frontmatter stripped, so an agent without the skill installed can pull guidance usable exactly as if the skill had loaded. - `--instructions` prints a short always-on snippet to append to a project's CLAUDE.md/AGENTS.md, so agents treat open mcpdo connections as part of their toolset on every turn (skills load only on demand). - `--skill-path` (replaces `--path`) prints the installable SKILL.md location for skill runtimes. Flags are mutually exclusive. - SKILL.md gains a "Make it always-on (optional)" bullet pointing at `agent-help --instructions`. - Test hygiene from ed064808: fix `createStyle` calls to the real `(ansi: boolean)` signature and a prefer-const slip; neither changed runtime behavior, both now caught by full validate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/agent-help.test.ts | 35 ++++++- .../__tests__/format-connection.test.ts | 7 +- .../__tests__/mcp-elicitation.test.ts | 2 +- clients/daemon-cli/src/connection/mcp.ts | 96 +++++++++++++++---- skills/mcpdo/SKILL.md | 5 + 5 files changed, 116 insertions(+), 29 deletions(-) diff --git a/clients/daemon-cli/__tests__/agent-help.test.ts b/clients/daemon-cli/__tests__/agent-help.test.ts index f2d4ba4c9d..86aaf605ab 100644 --- a/clients/daemon-cli/__tests__/agent-help.test.ts +++ b/clients/daemon-cli/__tests__/agent-help.test.ts @@ -3,18 +3,45 @@ import { existsSync } from "node:fs"; import { runMcp } from "./helpers/mcp-runner.js"; describe("mcpdo agent-help", () => { - it("prints skills/mcpdo/SKILL.md content, including its frontmatter", async () => { + it("prints the SKILL.md body with the frontmatter stripped", async () => { const result = await runMcp(["agent-help"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name: mcpdo"); expect(result.stdout).toContain("mcpdo connect"); + expect(result.stdout).not.toContain("name: mcpdo"); + expect(result.stdout.startsWith("---")).toBe(false); }); - it("--path prints the resolved SKILL.md file path", async () => { - const result = await runMcp(["agent-help", "--path"]); + it("--skill explicitly selects the default guide output", async () => { + const [bare, explicit] = [ + await runMcp(["agent-help"]), + await runMcp(["agent-help", "--skill"]), + ]; + expect(explicit.exitCode).toBe(0); + expect(explicit.stdout).toBe(bare.stdout); + }); + + it("--skill-path prints the resolved SKILL.md file path", async () => { + const result = await runMcp(["agent-help", "--skill-path"]); expect(result.exitCode).toBe(0); const printedPath = result.stdout.trim(); expect(printedPath.endsWith("skills/mcpdo/SKILL.md")).toBe(true); expect(existsSync(printedPath)).toBe(true); }); + + it("--instructions prints the always-on CLAUDE.md/AGENTS.md snippet", async () => { + const result = await runMcp(["agent-help", "--instructions"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("part of your available toolset"); + expect(result.stdout).toContain("mcpdo agent-help"); + }); + + it("rejects --instructions combined with --skill-path", async () => { + const result = await runMcp([ + "agent-help", + "--instructions", + "--skill-path", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("mutually exclusive"); + }); }); diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index ea3b980570..a58cfea768 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -619,10 +619,7 @@ describe("formatElicitationPendingHuman", () => { origin: "server-request", expiresAt: 0, }; - const styled = formatElicitationPendingHuman( - info, - createStyle({ color: true, links: true }), - ); + const styled = formatElicitationPendingHuman(info, createStyle(true)); expect(styled).toContain("https://example.com/signup?flow=abc"); expect(styled).toContain("\u001b]8;;https://example.com/signup?flow=abc"); expect(styled).toContain("elicitation/respond e-u --done"); @@ -630,7 +627,7 @@ describe("formatElicitationPendingHuman", () => { const unsafe = formatElicitationPendingHuman( { ...info, url: "file:///etc/passwd" }, - createStyle({ color: true, links: true }), + createStyle(true), ); expect(unsafe).toContain("file:///etc/passwd"); expect(unsafe).not.toContain("\u001b]8"); diff --git a/clients/daemon-cli/__tests__/mcp-elicitation.test.ts b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts index 1c4416bf31..17a0525ebf 100644 --- a/clients/daemon-cli/__tests__/mcp-elicitation.test.ts +++ b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts @@ -22,7 +22,7 @@ import type { ElicitationPendingInfo } from "../src/daemon/protocol.js"; describe("mcp non-interactive elicitation (e2e)", () => { let storageDir: string | undefined; let configPath: string | undefined; - let ttyDescriptors: Array<{ + const ttyDescriptors: Array<{ stream: NodeJS.ReadStream | NodeJS.WriteStream; desc: PropertyDescriptor | undefined; }> = []; diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index 1e3daffc42..d343cf2de2 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -970,30 +970,88 @@ function resolveAgentSkillPath(): string | undefined { return candidates.find((candidate) => existsSync(candidate)); } +/** + * The always-on awareness snippet for a project's CLAUDE.md / AGENTS.md. + * Skills are pull-only (an agent sees just the description until something + * triggers a load), so task-shaped prompts that never mention MCP won't + * activate the skill; a line in an always-in-context instructions file is + * the reliable mechanism for standing awareness. + */ +const AGENT_INSTRUCTIONS_SNIPPET = + "mcpdo manages connections to additional MCP servers. Treat the tools, " + + "resources, and prompts on its open connections (`mcpdo connections/list`) " + + "as part of your available toolset — check them before deciding a task " + + "can't be done, and include them when asked what tools or MCP servers " + + "you have. Run `mcpdo agent-help` for the usage guide.\n"; + +/** Returns SKILL.md content with the YAML frontmatter block removed. */ +function stripFrontmatter(content: string): string { + if (!content.startsWith("---\n")) return content; + const end = content.indexOf("\n---\n", 4); + if (end === -1) return content; + return content.slice(end + 5).replace(/^\n+/, ""); +} + function registerAgentHelpCommand(program: CommandType): void { program .command("agent-help") .description( - "Print mcpdo's SKILL.md content — a concise, agent-oriented guide for " + - "coding agents/LLMs (also the file `npx skills` installs). Use " + - "--path to print its file location instead of its contents.", + "Print an agent-oriented usage guide, comparable to the mcpdo SKILL. " + + "Agents should read this before using other mcpdo commands.", ) - .option("--path", "Print the resolved file path instead of its contents") - .action(async (o: { path?: boolean }) => { - const skillPath = resolveAgentSkillPath(); - if (!skillPath) { - throw new CliExitCodeError( - EXIT_CODES.USAGE, - "Could not locate skills/mcpdo/SKILL.md relative to this install.", - { code: "agent_help_not_found" }, - ); - } - if (o.path === true) { - await awaitableLog(skillPath + "\n"); - return; - } - await awaitableLog(readFileSync(skillPath, "utf8")); - }); + .option( + "--skill", + "Print the full usage guide — the mcpdo SKILL.md body, usable as if " + + "the skill had been loaded (default when no option is given)", + ) + .option( + "--instructions", + "Print a short snippet to append to a project's CLAUDE.md/AGENTS.md " + + "so agents treat mcpdo connections as part of their toolset on " + + "every turn (skills and this guide load only on demand). " + + "Pipeable: mcpdo agent-help --instructions >> AGENTS.md", + ) + .option( + "--skill-path", + "Print the path of the installable SKILL.md file — this guide plus " + + "its skill frontmatter — for skill runtimes " + + "(e.g. copy into ~/.claude/skills/mcpdo/)", + ) + .action( + async (o: { + skill?: boolean; + instructions?: boolean; + skillPath?: boolean; + }) => { + const picked = [o.skill, o.instructions, o.skillPath].filter( + (v) => v === true, + ).length; + if (picked > 1) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "--skill, --instructions, and --skill-path are mutually exclusive.", + { code: "agent_help_flag_conflict" }, + ); + } + if (o.instructions === true) { + await awaitableLog(AGENT_INSTRUCTIONS_SNIPPET); + return; + } + const skillPath = resolveAgentSkillPath(); + if (!skillPath) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Could not locate skills/mcpdo/SKILL.md relative to this install.", + { code: "agent_help_not_found" }, + ); + } + if (o.skillPath === true) { + await awaitableLog(skillPath + "\n"); + return; + } + await awaitableLog(stripFrontmatter(readFileSync(skillPath, "utf8"))); + }, + ); } function registerRpcCommands(program: CommandType): void { diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 0ed538af7e..787d3a4dc9 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -49,6 +49,11 @@ more). state, and daemon state all have read commands; never list or read `~/.mcp-inspector` directly. The one exception is _editing_ the catalog (below). +- **Make it always-on (optional).** Skills load only on demand; for standing + awareness of mcpdo in a project, append `mcpdo agent-help --instructions` + output to the project's `CLAUDE.md`/`AGENTS.md`. (`mcpdo agent-help` + prints this guide; `--skill-path` prints the installable skill file's + path.) ## The catalog From 9baebbfc4ea47088e10c03485abb4720e9659ddf Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 08:27:32 -0700 Subject: [PATCH 49/69] mcpdo evals: fix two harness OAuth bugs, run both agents, keep failure artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness fixes (both produced silent never-authorized misses): - Concurrent samples' detached auth helpers all defaulted to callback port 6276 and collided (EADDRINUSE, or consent redirected to a dead helper). Each sample now sets MCP_OAUTH_CALLBACK_URL=http://127.0.0.1:0/oauth/callback — ephemeral ports are already supported end-to-end by the product. - The consent clicker scanned each stream chunk separately, so an authorize URL split across chunks was never detected. It now scans joined per-stream text (streamText), like the rest of the matchers; unit test covers a mid-URL split. Clicks are now logged. Runner UX: - AGENT defaults to "all": trigger + behavior suites run for claude and copilot back to back (AGENT=claude|copilot still selects one). - On a behavior miss the sample's hermetic env dir is preserved under ~/.cache/mcpdo-skill-eval/failures/ (raw agent stream, shim transcript, case, catalog, server configs) and the miss report prints its path. - Miss diagnostics show per-call timing offsets. Validated: full AGENT=all suite green — both agents 8/8 trigger @100% and 8/8 behavior @3/3, including the secure-add OAuth case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- scripts/skill-eval-mcpdo.mjs | 118 +++++++++++++++++++++++------- scripts/skill-eval-mcpdo.test.mjs | 57 +++++++++++++++ scripts/skill-eval.mjs | 18 ++++- 3 files changed, 166 insertions(+), 27 deletions(-) diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index e3897ac350..cf46282456 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -69,6 +69,7 @@ import { existsSync, mkdirSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -129,7 +130,9 @@ const SERVER_LAUNCHER = path.join( const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8); const RUNS = Number(process.env.RUNS ?? 3); const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4); -const AGENT = process.env.AGENT ?? "claude"; +// `all` runs every known agent in sequence; a single agent name narrows the +// run (e.g. AGENT=claude for cheaper iteration). +const AGENT = process.env.AGENT ?? "all"; // Behavior knobs are separate from the trigger ones: a multi-turn agentic // run costs an order of magnitude more than a one-turn trigger sample, and // its hit rate is honestly lower — 0.5 strict to start, tightened as the @@ -405,10 +408,16 @@ export async function makeBehaviorEnv( MCP_INSPECTOR_DAEMON_TOKEN: randomBytes(32).toString("base64url"), MCP_STORAGE_DIR: storageDir, MCP_CATALOG_PATH: catalogPath, + // Ephemeral OAuth callback port per sample: the default is a fixed port + // (6276), which concurrent samples' detached auth helpers fight over — + // the loser's consent redirect lands on the winner's helper and sign-in + // silently never completes (measured: intermittent secure-add misses + // with endless `authorized: false` polls). + MCP_OAUTH_CALLBACK_URL: "http://127.0.0.1:0/oauth/callback", MCPDO_EVAL_REAL: REAL_BIN, MCPDO_EVAL_LOG: logPath, }; - const teardown = async () => { + const teardown = async ({ keepEnvDir = false } = {}) => { // Daemon first (it may hold connections into the HTTP fixtures), then // the fixtures. Direct spawn of the real build, not the shim: teardown // must not appear in the transcript, and must work even if the shim is @@ -431,9 +440,9 @@ export async function makeBehaviorEnv( }); }); for (const s of httpServers) await s.stop().catch(() => {}); - rmSync(envDir, { recursive: true, force: true }); + if (!keepEnvDir) rmSync(envDir, { recursive: true, force: true }); }; - return { env, logPath, teardown }; + return { env, logPath, envDir, teardown }; } /** Parse the shim transcript; tolerate a torn final line, never silent-drop. */ @@ -509,8 +518,13 @@ export function startConsentClicker(logPath) { if (inFlight) return; const urls = new Set(); for (const record of readTranscript(logPath)) { - for (const event of record.events ?? []) { - for (const url of event.data?.match?.(AUTHORIZE_URL_RE) ?? []) { + // Scan joined per-stream text, not individual chunks: a long authorize + // URL (e.g. inside pretty-printed `--format json` output) can be split + // across stream chunks, and a per-chunk scan then sees only fragments + // (measured: copilot misses where sign-in silently never happened). + for (const stream of ["stdout", "stderr"]) { + for (const url of streamText(record, stream).match(AUTHORIZE_URL_RE) ?? + []) { if (!clicked.has(url)) urls.add(url); } } @@ -522,6 +536,7 @@ export function startConsentClicker(logPath) { for (const url of urls) { try { await clickConsent(url); + console.error(` autoConsent: clicked ${new URL(url).pathname}`); } catch (err) { console.error(` autoConsent: click failed — ${err.message}`); } @@ -535,27 +550,45 @@ export function startConsentClicker(logPath) { }; } +/** + * Where a failed sample's artifacts are preserved for diagnosis: the shim + * transcript, the raw agent stream, the composed catalog/server configs, and + * the case itself. Never auto-cleaned — delete by hand when done. + */ +export const FAILURES_DIR = path.join( + os.homedir(), + ".cache", + "mcpdo-skill-eval", + "failures", +); + /** * Run one behavior sample: fresh sandbox + hermetic env, one agent session, - * transcript scored against the case's `expectCalls`. + * transcript scored against the case's `expectCalls`. On a miss the sample's + * env dir (transcript, agent stream, configs, case) is preserved under + * {@link FAILURES_DIR} and its path returned as `artifactsDir`. * * @param {object} c A behavior case. + * @param {string} agent One of `AGENTS`. * @returns {Promise<{ hit: boolean, failures: string[], calls: number }>} */ -async function runBehaviorSample(c) { +async function runBehaviorSample(c, agent) { const sandbox = makeSandbox(); - const { env, logPath, teardown } = await makeBehaviorEnv( + const { env, logPath, envDir, teardown } = await makeBehaviorEnv( sandbox, caseServers(c), ); const clicker = c.autoConsent === true ? startConsentClicker(logPath) : null; + let keepEnvDir = false; + let artifactsDir = null; try { await runPrompt(c.prompt, { cwd: sandbox, - agent: AGENT, + agent, maxTurns: BEHAVIOR_TURNS, env, agentArgsFn: behaviorAgentArgs, + rawLogPath: path.join(envDir, "agent-session.ndjson"), }); const records = readTranscript(logPath); const { ok, failures } = evalExpectCalls(c.expectCalls, records); @@ -566,13 +599,36 @@ async function runBehaviorSample(c) { : records.map((r) => ({ argv: r.argv, exit: r.exit, + start: r.start, + end: r.end, out: streamText(r, "stdout").slice(0, 300), err: streamText(r, "stderr").slice(0, 300), })); - return { hit: ok, failures, calls: records.length, transcript }; + if (!ok) { + keepEnvDir = true; + writeFileSync( + path.join(envDir, "case.json"), + JSON.stringify({ agent, case: c, failures }, null, 2), + ); + artifactsDir = path.join( + FAILURES_DIR, + `${new Date().toISOString().replace(/[:.]/g, "-")}-${agent}-${path.basename(envDir)}`, + ); + } + return { + hit: ok, + failures, + calls: records.length, + transcript, + artifactsDir, + }; } finally { clicker?.stop(); - await teardown(); + await teardown({ keepEnvDir }); + if (keepEnvDir && artifactsDir !== null) { + mkdirSync(FAILURES_DIR, { recursive: true }); + renameSync(envDir, artifactsDir); + } rmSync(sandbox, { recursive: true, force: true }); } } @@ -592,12 +648,13 @@ async function pool(items, n, fn) { } async function main() { - if (!AGENTS.includes(AGENT)) { + if (AGENT !== "all" && !AGENTS.includes(AGENT)) { console.error( - `skills:eval:mcpdo — unknown AGENT \`${AGENT}\`; known: ${AGENTS.join(", ")}`, + `skills:eval:mcpdo — unknown AGENT \`${AGENT}\`; known: all, ${AGENTS.join(", ")}`, ); process.exit(1); } + const agents = AGENT === "all" ? [...AGENTS] : [AGENT]; for (const [name, value] of [ ["THRESHOLD", THRESHOLD], ["BEHAVIOR_THRESHOLD", BEHAVIOR_THRESHOLD], @@ -617,10 +674,13 @@ async function main() { ); } let failed = 0; - failed += await runTriggerSection( - trigger.filter(byMatch).map((c) => ({ ...c, from: SKILL_NAME })), - ); - failed += await runBehaviorSection(behavior.filter(byMatch)); + for (const agent of agents) { + failed += await runTriggerSection( + trigger.filter(byMatch).map((c) => ({ ...c, from: SKILL_NAME })), + agent, + ); + failed += await runBehaviorSection(behavior.filter(byMatch), agent); + } process.exit(failed > 0 ? 1 : 0); } @@ -629,18 +689,18 @@ async function main() { * * @returns {Promise} Failed case count. */ -async function runTriggerSection(cases) { +async function runTriggerSection(cases, agent) { if (cases.length === 0) return 0; const sandbox = makeSandbox(); console.log( - `skills:eval:mcpdo trigger — ${cases.length} cases x ${RUNS} runs, agent ${AGENT}, sandbox ${sandbox}`, + `skills:eval:mcpdo trigger — ${cases.length} cases x ${RUNS} runs, agent ${agent}, sandbox ${sandbox}`, ); const samples = cases.flatMap((c) => Array.from({ length: RUNS }, () => c)); try { const results = await pool(samples, CONCURRENCY, async (c) => { const invoked = await runPrompt(c.prompt, { cwd: sandbox, - agent: AGENT, + agent, maxTurns: 1, }); return { c, invoked }; @@ -657,7 +717,7 @@ async function runTriggerSection(cases) { threshold: THRESHOLD, chainThreshold: 0.5, chainMaxTurns: 1, - agent: AGENT, + agent, }, ); for (const line of lines) console.log(line); @@ -673,7 +733,7 @@ async function runTriggerSection(cases) { * * @returns {Promise} Failed case count. */ -async function runBehaviorSection(cases) { +async function runBehaviorSection(cases, agent) { if (cases.length === 0) return 0; if (process.platform === "win32") { console.log( @@ -689,14 +749,14 @@ async function runBehaviorSection(cases) { return 1; } console.log( - `skills:eval:mcpdo behavior — ${cases.length} cases x ${BEHAVIOR_RUNS} runs, agent ${AGENT}, budget ${BEHAVIOR_TURNS} turns`, + `skills:eval:mcpdo behavior — ${cases.length} cases x ${BEHAVIOR_RUNS} runs, agent ${agent}, budget ${BEHAVIOR_TURNS} turns`, ); const samples = cases.flatMap((c) => Array.from({ length: BEHAVIOR_RUNS }, () => c), ); const results = await pool(samples, CONCURRENCY, async (c) => ({ c, - ...(await runBehaviorSample(c)), + ...(await runBehaviorSample(c, agent)), })); let failed = 0; for (const c of cases) { @@ -713,9 +773,15 @@ async function runBehaviorSection(cases) { console.log( ` miss (${r.calls} mcpdo calls): ${r.failures.join("; ")}`, ); + if (r.artifactsDir) console.log(` artifacts: ${r.artifactsDir}`); + const t0 = r.transcript?.[0]?.start; for (const t of r.transcript ?? []) { + const at = + typeof t.start === "number" && typeof t0 === "number" + ? ` @${((t.start - t0) / 1000).toFixed(1)}s+${((t.end - t.start) / 1000).toFixed(1)}s` + : ""; console.log( - ` $ mcpdo ${t.argv.join(" ")} -> ${t.exit}` + + ` $ mcpdo ${t.argv.join(" ")} -> ${t.exit}${at}` + (t.out ? `\n out: ${t.out.replace(/\n/g, "\\n")}` : "") + (t.err ? `\n err: ${t.err.replace(/\n/g, "\\n")}` : ""), ); diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs index 85d2df60a9..be40d2dafc 100644 --- a/scripts/skill-eval-mcpdo.test.mjs +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -305,6 +305,63 @@ test("readTranscript: missing file and torn tail line", () => { } }); +test("consent clicker: finds an authorize URL split across stream chunks", async () => { + const { createServer } = await import("node:http"); + const hits = { callback: 0 }; + const server = createServer((req, res) => { + const u = new URL(req.url, "http://127.0.0.1"); + if (u.pathname === "/oauth/authorize" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("
consent
"); + } else if (u.pathname === "/oauth/authorize" && req.method === "POST") { + res.writeHead(302, { + Location: `http://127.0.0.1:${server.address().port}/cb?code=x&state=s`, + }); + res.end(); + } else if (u.pathname === "/cb") { + hits.callback++; + res.writeHead(200); + res.end("done"); + } else { + res.writeHead(404); + res.end(); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + const authUrl = `http://127.0.0.1:${port}/oauth/authorize?client_id=c&state=s`; + const cut = authUrl.indexOf("authorize?") + 12; // mid-query split + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); + const logPath = path.join(dir, "log.ndjson"); + writeFileSync( + logPath, + JSON.stringify({ + argv: ["connect", "secure"], + exit: 0, + events: [ + { + t: 1, + stream: "stdout", + data: `"authUrl": "${authUrl.slice(0, cut)}`, + }, + { t: 2, stream: "stdout", data: `${authUrl.slice(cut)}"` }, + ], + }) + "\n", + ); + const clicker = startConsentClicker(logPath); + try { + const deadline = Date.now() + 5000; + while (hits.callback === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + assert.equal(hits.callback, 1); + } finally { + clicker.stop(); + server.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + test("consent clicker: approves each authorize URL from the transcript once", async () => { const { createServer } = await import("node:http"); const hits = { get: 0, post: 0, callback: 0 }; diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index cf243b8bdd..27f7bce2d3 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -49,7 +49,13 @@ // the run has established it needs one — so they never share a column. import { spawn } from "node:child_process"; -import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { + readFileSync, + existsSync, + readdirSync, + statSync, + appendFileSync, +} from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { cliSpawnArgs, probeCliVersion } from "./lib/claude-cli.mjs"; @@ -683,6 +689,9 @@ export function runPrompt( // flag). Defaults preserve this file's read-only trigger policy exactly. env = null, agentArgsFn = agentArgs, + // Optional raw capture of the agent's stdout stream (NDJSON events) for + // post-mortem diagnosis of failed samples. + rawLogPath = null, } = {}, ) { return new Promise((resolve, reject) => { @@ -726,6 +735,13 @@ export function runPrompt( // stream rather than restarting at each read. let turnOffset = 0; p.stdout.on("data", (chunk) => { + if (rawLogPath !== null) { + try { + appendFileSync(rawLogPath, chunk); + } catch { + // Diagnostics only — never fail the run over the raw log. + } + } if (stopped) return; const parsed = collect(buf + chunk.toString(), turnOffset); buf = parsed.rest; From ea1cf4624d79b048a8242dc669d9727ad9846bb6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 08:51:27 -0700 Subject: [PATCH 50/69] mcpdo: restore per-file coverage thresholds with targeted tests CI coverage fell below the 90% per-file gates in four files touched by recent auth/elicitation work. Cover the gaps: - auth-helper: stdio configs (no marker), non-Error flow failures, stdin error/timeout, stdout EPIPE guard, helper spawn failure, and noise lines (blank/malformed/unknown events) before the auth URL. - authorize: non-EMA connect failures rethrown unchanged; caller-provided makeNavigation overrides the CLI default navigation. - format-human: colorLevel warning/debug/notice buckets and empty-URI passthrough via formatStreamEventHuman. - elicitation-park: waitForElicitation with an already-pending frame; forClient/cancelForConnection ignoring non-matching entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/auth-helper.test.ts | 153 ++++++++++++++++++ .../daemon-cli/__tests__/authorize.test.ts | 27 ++++ .../__tests__/daemon-elicitation-park.test.ts | 36 +++++ .../__tests__/format-connection.test.ts | 21 +++ 4 files changed, 237 insertions(+) diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts index c1bea16bb1..d01a134195 100644 --- a/clients/daemon-cli/__tests__/auth-helper.test.ts +++ b/clients/daemon-cli/__tests__/auth-helper.test.ts @@ -162,6 +162,63 @@ describe("auth-helper", () => { }); }); + it("skips marker reuse for stdio configs and still spawns the helper", async () => { + const script = writeHelperScript(` + process.stdin.resume(); + process.stdin.on("end", () => { + process.stdout.write( + JSON.stringify({ event: "auth_url", url: "https://as.example/stdio" }) + "\\n", + ); + }); + `); + const url = await obtainPendingAuthUrl( + { type: "stdio", command: "srv" }, + undefined, + { helperArgv1: script }, + ); + expect(url).toBe("https://as.example/stdio"); + }); + + it("skips blank, malformed, and unknown-event lines before the URL", async () => { + const script = writeHelperScript(` + process.stdin.resume(); + process.stdin.on("end", () => { + process.stdout.write( + "\\n" + + "not json\\n" + + JSON.stringify({ event: "progress" }) + "\\n" + + JSON.stringify({ event: "auth_url", url: "https://as.example/after-noise" }) + "\\n", + ); + }); + `); + const url = await obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: script }, + ); + expect(url).toBe("https://as.example/after-noise"); + }); + + it("maps a helper spawn failure to auth_required", async () => { + const originalExecPath = process.execPath; + // A nonexistent interpreter makes spawn emit `error` instead of `exit`. + process.execPath = path.join(dir, "no-such-node"); + try { + await expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: path.join(dir, "unused.mjs") }, + ), + ).rejects.toMatchObject({ + envelope: { code: "auth_required" }, + message: expect.stringContaining("Failed to spawn"), + }); + } finally { + process.execPath = originalExecPath; + } + }); + it("fails when the helper exits before producing a URL", async () => { const script = writeHelperScript(`process.exit(2);`); await expect( @@ -305,6 +362,102 @@ describe("auth-helper", () => { expect(events).toEqual([{ event: "error", message: "flow exploded" }]); }); + it("emits the URL without writing a marker for stdio configs", async () => { + authorizeInFrontend.mockImplementation( + async ( + _config: unknown, + _settings: unknown, + options: { + makeNavigation: (control: { armed: boolean }) => CallbackNavigation; + }, + ) => { + const navigation = options.makeNavigation({ armed: true }); + navigation.navigateToAuthorization( + new URL("https://as.example/authorize?stdio=1"), + ); + }, + ); + const restoreStdin = stubStdin( + JSON.stringify({ serverConfig: { type: "stdio", command: "srv" } }), + ); + const stdout = captureStdout(); + try { + await runAuthHelper(); + // The EPIPE guard on stdout must swallow late write errors. + process.stdout.emit("error", new Error("EPIPE")); + } finally { + stdout.restore(); + restoreStdin(); + } + const events = stdout + .lines() + .map((l) => JSON.parse(l) as { event: string }); + expect(events.map((e) => e.event)).toEqual(["auth_url", "done"]); + // No url on the config → no marker file anywhere in the daemon dir. + expect(fs.readdirSync(dir).filter((f) => f.includes("auth"))).toEqual([]); + }); + + it("stringifies a non-Error flow failure in the error event", async () => { + authorizeInFrontend.mockRejectedValueOnce("string boom"); + const restoreStdin = stubStdin( + JSON.stringify({ + serverConfig: { type: "streamable-http", url: SERVER_URL }, + }), + ); + const stdout = captureStdout(); + try { + await expect(runAuthHelper()).rejects.toBe("string boom"); + } finally { + stdout.restore(); + restoreStdin(); + } + const events = stdout + .lines() + .map((l) => JSON.parse(l) as { event: string; message?: string }); + expect(events).toEqual([{ event: "error", message: "string boom" }]); + }); + + it("rejects when stdin errors before EOF", async () => { + const stream = new PassThrough(); + const descriptor = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { + value: stream, + configurable: true, + }); + const stdout = captureStdout(); + try { + const pending = runAuthHelper(); + stream.emit("error", new Error("broken pipe")); + await expect(pending).rejects.toThrow("broken pipe"); + } finally { + stdout.restore(); + if (descriptor) Object.defineProperty(process, "stdin", descriptor); + } + }); + + it("times out when the parent never sends params", async () => { + vi.useFakeTimers(); + const stream = new PassThrough(); + const descriptor = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { + value: stream, + configurable: true, + }); + const stdout = captureStdout(); + try { + const pending = runAuthHelper(); + const expectation = expect(pending).rejects.toThrow( + /timed out waiting for params/, + ); + await vi.advanceTimersByTimeAsync(30_000); + await expectation; + } finally { + vi.useRealTimers(); + stdout.restore(); + if (descriptor) Object.defineProperty(process, "stdin", descriptor); + } + }); + it("rejects params without a serverConfig", async () => { const restoreStdin = stubStdin(JSON.stringify({})); const stdout = captureStdout(); diff --git a/clients/daemon-cli/__tests__/authorize.test.ts b/clients/daemon-cli/__tests__/authorize.test.ts index a65a2a8146..420ccf50a8 100644 --- a/clients/daemon-cli/__tests__/authorize.test.ts +++ b/clients/daemon-cli/__tests__/authorize.test.ts @@ -107,6 +107,33 @@ describe("authorizeInFrontend", () => { ); }); + it("rethrows non-EMA connect failures unchanged", async () => { + connectSpy.mockRejectedValue(new Error("network down")); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await expect( + authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ), + ).rejects.toThrow("network down"); + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("uses a caller-provided navigation instead of the CLI default", async () => { + connectSpy.mockResolvedValue(undefined); + const makeNavigation = vi.fn().mockReturnValue({ navigate: vi.fn() }); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + { makeNavigation }, + ); + expect(makeNavigation).toHaveBeenCalledTimes(1); + expect(navigationSpy).not.toHaveBeenCalled(); + }); + it("maps EmaClientNotConfiguredError to actionable mcpdo guidance", async () => { const { EmaClientNotConfiguredError } = await import("@inspector/core/auth/ema/clientConfigError.js"); diff --git a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts index 4231e78254..6edaa0b46f 100644 --- a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts +++ b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts @@ -530,6 +530,42 @@ describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => expect(channel.pendingFrame()).toBeNull(); }); + it("waitForElicitation resolves immediately when a request is already pending", async () => { + const channel = new ParkingElicitationChannel(); + const pending = channel.request(frame("e1")); + const seen = await channel.waitForElicitation(); + expect(seen.elicitationId).toBe("e1"); + channel.close(new Error("teardown")); + await expect(pending).rejects.toThrow("teardown"); + }); + + it("forClient and cancelForConnection ignore non-matching entries", async () => { + const registry = new ElicitationParkRegistry(0); + const channel = new ParkingElicitationChannel(); + const pending = channel.request(frame("e1")); + const client = {} as InspectorClient; + registry.add({ + info: { + elicitationId: "e1", + connection: "srv", + method: "tools/call", + mode: "form", + message: "hi", + origin: "server-request", + }, + client, + channel, + outcome: new Promise(() => {}), + }); + expect(registry.forClient({} as InspectorClient)).toBeUndefined(); + expect(registry.forClient(client)).toBeDefined(); + // A different connection's teardown must not cancel this parked call. + registry.cancelForConnection("other"); + expect(registry.forClient(client)).toBeDefined(); + registry.cancelAll(); + await expect(pending).rejects.toThrow(/going away/); + }); + it("cancelAll settles every parked entry", async () => { const registry = new ElicitationParkRegistry(0); const channel = new ParkingElicitationChannel(); diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index a58cfea768..73f938dc57 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -486,6 +486,27 @@ describe("format-human", () => { it("formats stream events and rpc dispatch", () => { expect(formatStreamEventHuman(null)).toBe("null"); + // Empty URI: formatUri must pass it through without linkifying. + expect(formatStreamEventHuman({ type: "subscribed" })).toBe("Subscribed: "); + // colorLevel groups: red, yellow, dim, and the default (cyan) bucket. + const s = createStyle(true); + for (const [level, colored] of [ + ["error", s.red("error")], + ["warning", s.yellow("warning")], + ["debug", s.dim("debug")], + ["notice", s.dim("notice")], + ["info", s.cyan("info")], + ] as const) { + expect( + formatStreamEventHuman( + { + direction: "notification", + message: { params: { level, data: "x" } }, + }, + s, + ), + ).toContain(`[${colored}]`); + } expect(formatStreamEventHuman({ type: "subscribed", uri: "u" })).toBe( "Subscribed: u", ); From 25395c2f594a41f8afdbc530ef3b99da0ca42dfe Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 09:09:01 -0700 Subject: [PATCH 51/69] mcpdo evals: spawn agents with a minimal environment, not the developer's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #1783): the behavior evals' shell allow-list looked like a containment boundary but is not one — approval patterns prefix-match (`mcpdo x && anything` passes) and `mcpdo connect` launches arbitrary stdio commands by design. The agent under test is a nondeterministic model with shell access; its actions are untrusted. - runPrompt no longer spreads process.env into the agent: agentEnv() picks process basics (PATH, HOME, locale, proxies) plus only the agent's own auth/config vars (ANTHROPIC_/CLAUDE_ for claude, GITHUB_/GH_/COPILOT_ for copilot). Exported credentials for anything else stay out. - The behaviorAgentArgs doc now states the real model: approval scoping is drift reduction for a cooperating model, env is minimized, and hard isolation is the runner's job (container/VM/dedicated user of choice — there is no portable OS sandbox worth shipping here). Validated: harness unit tests (93) green; live helpdesk 3/3 and secure-add OAuth 1/1 on both agents under the minimal env. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- scripts/skill-eval-mcpdo.mjs | 20 +++++++--- scripts/skill-eval.mjs | 73 ++++++++++++++++++++++++++++++++++-- scripts/skill-eval.test.mjs | 64 +++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 9 deletions(-) diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index cf46282456..dd8b59c19b 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -232,9 +232,17 @@ export function loadCases() { /** * Agent arguments for a BEHAVIOR run: the trigger policy plus shell, scoped - * to mcpdo by command-level approval. Both syntaxes were probed live (see - * the header): unapproved effectful commands fail fast and the run - * continues, so containment costs turns, not hangs. + * to mcpdo by command-level approval. + * + * SECURITY NOTE — this scoping is NOT a boundary. The approval patterns are + * prefix matches (`mcpdo x && anything` passes), and `mcpdo connect` itself + * launches arbitrary stdio commands by design. What the patterns do is keep a + * COOPERATING model from drifting into unrelated shell work (probed live: + * unapproved effectful commands fail fast and the run continues, so the + * denials cost turns, not hangs). The agent's command environment is + * minimized separately (see agentEnv in skill-eval.mjs); a runner who wants + * hard isolation from a misbehaving model should run this suite inside an + * OS-level sandbox of their choice (container, VM, dedicated user). * * Passed to `runPrompt` as `agentArgsFn` — a replacement, because the * trigger policy's `--deny-tool shell` / `--disallowedTools Bash` cannot be @@ -252,9 +260,9 @@ export function behaviorAgentArgs(agent, maxTurns) { // No `--available-tools`: its availability names differ from the // approval-pattern names (the shell tool is `bash` in events but // `shell(...)` in patterns), and naming it wrong silently removes the - // tool — after which the model FABRICATES command output. Approval - // scoping alone contains the run: everything unapproved is auto-denied - // in headless mode. + // tool — after which the model FABRICATES command output. Everything + // unapproved is auto-denied in headless mode (drift reduction, not + // containment — see the function doc). "--allow-tool", "view,glob,grep,skill", "--allow-tool", diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 27f7bce2d3..22aa20a968 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -657,6 +657,72 @@ export function stopLiveCopilotRuns(runs = liveCopilotRuns) { return n; } +/** + * Minimal environment for a spawned agent. The agent under test is a + * nondeterministic model with shell access — its actions are untrusted, so it + * must not inherit the developer's full environment (arbitrary exported + * credentials, cloud keys, tokens). Instead of spreading `process.env`, pick + * only what an agent CLI needs to run and authenticate: + * + * - process basics (PATH, HOME, TMPDIR, locale, terminal), + * - proxy configuration, and + * - the agent's OWN auth/config vars (ANTHROPIC_ and CLAUDE_ prefixes for + * claude; GITHUB_, GH_, and COPILOT_ prefixes for copilot) — the agent + * needs its credentials, but the other agent's (and everything else) + * stays out. + * + * HOME remains real because both CLIs keep auth state under it; env + * minimization limits what leaks into the model's command environment, not + * filesystem access. OS-level isolation (container, VM, dedicated user) is + * the runner's responsibility if they want a hard boundary. + * + * @param {string} agent `claude` or `copilot`. + * @param {NodeJS.ProcessEnv} [source] Injectable for tests. + * @returns {Record} + */ +export function agentEnv(agent, source = process.env) { + const base = [ + "PATH", + "HOME", + "TMPDIR", + "TERM", + "SHELL", + "USER", + "LOGNAME", + "LANG", + "LC_ALL", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + // Windows equivalents; harmless no-ops elsewhere. + "SYSTEMROOT", + "APPDATA", + "LOCALAPPDATA", + "USERPROFILE", + "TEMP", + "TMP", + "PATHEXT", + "COMSPEC", + ]; + const prefixes = + agent === "copilot" + ? ["GITHUB_", "GH_", "COPILOT_", "XDG_"] + : ["ANTHROPIC_", "CLAUDE_", "XDG_"]; + const env = {}; + for (const key of Object.keys(source)) { + if (source[key] === undefined) continue; + if (base.includes(key) || prefixes.some((p) => key.startsWith(p))) { + env[key] = source[key]; + } + } + return env; +} + /** * Drive one fresh session and return the payloads the `Skill` tool was called * with. @@ -681,8 +747,8 @@ export function runPrompt( agent = "claude", killFn = killTree, // Additive seams for the mcpdo BEHAVIOR eval (skill-eval-mcpdo.mjs): - // `env` merges over the inherited environment (the behavior eval puts a - // recording shim first on PATH and binds a private daemon), and + // `env` merges over the minimal agent environment (the behavior eval puts + // a recording shim first on PATH and binds a private daemon), and // `agentArgsFn` replaces the whole argument builder — replacement, not // appending, because a policy that must allow shell cannot be reached by // appending to one that denies it (`--deny-tool shell` has no inverse @@ -706,7 +772,8 @@ export function runPrompt( agentArgsFn(agent, maxTurns), { cwd, - ...(env ? { env: { ...process.env, ...env } } : {}), + // Never the full inherited environment: see agentEnv. + env: { ...agentEnv(agent), ...(env ?? {}) }, stdio: ["pipe", "pipe", "inherit"], // Its own process group, so `killTree` can reach the native binary the // wrapper starts. Windows has no groups; `taskkill /T` covers it. diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index d9a38d0046..b8d0ee0224 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -17,6 +17,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { + agentEnv, caseHit, chainHit, formatReport, @@ -1053,3 +1054,66 @@ test("a rejected Copilot run leaves the in-flight set", async () => { ); assert.equal(stopLiveCopilotRuns(), 0); }); + +test("agentEnv: agents get only process basics and their own credentials", () => { + const source = { + PATH: "/usr/bin", + HOME: "/Users/dev", + AWS_SECRET_ACCESS_KEY: "leak-me-not", + NPM_TOKEN: "leak-me-not", + ANTHROPIC_API_KEY: "claude-key", + CLAUDE_CODE_FLAG: "1", + GH_TOKEN: "copilot-key", + GITHUB_TOKEN: "copilot-key-2", + COPILOT_MODEL: "m", + XDG_CONFIG_HOME: "/Users/dev/.config", + }; + const claude = agentEnv("claude", source); + assert.equal(claude.PATH, "/usr/bin"); + assert.equal(claude.HOME, "/Users/dev"); + assert.equal(claude.ANTHROPIC_API_KEY, "claude-key"); + assert.equal(claude.CLAUDE_CODE_FLAG, "1"); + assert.equal(claude.XDG_CONFIG_HOME, "/Users/dev/.config"); + assert.ok(!("AWS_SECRET_ACCESS_KEY" in claude)); + assert.ok(!("NPM_TOKEN" in claude)); + assert.ok(!("GH_TOKEN" in claude)); + assert.ok(!("GITHUB_TOKEN" in claude)); + const copilot = agentEnv("copilot", source); + assert.equal(copilot.GH_TOKEN, "copilot-key"); + assert.equal(copilot.GITHUB_TOKEN, "copilot-key-2"); + assert.equal(copilot.COPILOT_MODEL, "m"); + assert.ok(!("ANTHROPIC_API_KEY" in copilot)); + assert.ok(!("AWS_SECRET_ACCESS_KEY" in copilot)); +}); + +test("runPrompt: spawned agent env is minimal plus the caller's overlay", async () => { + process.env.SKILL_EVAL_TEST_SECRET = "leak-me-not"; + try { + let seen; + const spawnFn = (command, args, options) => { + seen = options.env; + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stdin = { end: () => {} }; + queueMicrotask(() => { + child.stdout.emit( + "data", + Buffer.from(JSON.stringify({ type: "result", subtype: "success" }) + "\n"), + ); + child.emit("close", 0); + }); + return child; + }; + await runPrompt("p", { + agent: "claude", + spawnFn, + killFn: () => {}, + env: { MCPDO_EVAL_LOG: "/tmp/x" }, + }); + assert.ok(!("SKILL_EVAL_TEST_SECRET" in seen)); + assert.equal(seen.MCPDO_EVAL_LOG, "/tmp/x"); + assert.equal(seen.PATH, process.env.PATH); + } finally { + delete process.env.SKILL_EVAL_TEST_SECRET; + } +}); From bca417ec741976b9a5bea4cf645145afd4632b82 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 09:09:01 -0700 Subject: [PATCH 52/69] mcpdo evals: assert the helpdesk case's mapped argument values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #1783): the accept-path helpdesk case measured only the call sequence — any summary and any contact details produced elicitationPending then TCK-. Its stated point is mapping known facts into the requested schema, so assert them: `summary` on the tools/call and `contact_name`/`contact_email` on the elicitation/respond. valuesMatch is spelling-agnostic (key:=value, JSON positional, --tool-args-json all land in parsed args; plain key=value is rejected by the CLI itself). Re-baselined 3/3 on both claude and copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/evals/evals.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json index 13c5b8e0b8..7e1936df00 100644 --- a/clients/daemon-cli/evals/evals.json +++ b/clients/daemon-cli/evals/evals.json @@ -120,11 +120,16 @@ "cmd": "tools/call", "connection": "helpdesk", "tool": "submit_ticket", + "args": { "summary": "Printer on floor 3 is jammed" }, "exit": 0, "stdoutMatch": "elicitationPending|Input required" }, { "cmd": "elicitation/respond", + "args": { + "contact_name": "Ada Lovelace", + "contact_email": "ada@example.com" + }, "exit": 0, "stdoutMatch": "TCK-" } From 45544446ade32c5aa4eeaa1dab063a15019f6fa6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 09:19:00 -0700 Subject: [PATCH 53/69] mcpdo evals: prettier fix for skill-eval.test.mjs Post-format edit slipped past a re-check before push; format:check:scripts now clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- scripts/skill-eval.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index b8d0ee0224..cfc245f8f9 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -1098,7 +1098,9 @@ test("runPrompt: spawned agent env is minimal plus the caller's overlay", async queueMicrotask(() => { child.stdout.emit( "data", - Buffer.from(JSON.stringify({ type: "result", subtype: "success" }) + "\n"), + Buffer.from( + JSON.stringify({ type: "result", subtype: "success" }) + "\n", + ), ); child.emit("close", 0); }); From 1eacc6b052908b612aa52736dcaa559583327ae7 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 10:04:40 -0700 Subject: [PATCH 54/69] mcpdo: atomic sign-in flow reservation; gate authUrl OSC 8 links Two review findings (PR #1783): - The pending-marker check and helper spawn were not atomic: two concurrent non-TTY connects for the same server could both pass the check and spawn helpers that contend for the OAuth callback port. A per-server lock file (wx-exclusive create) now reserves the flow before spawning; the loser polls for the winner's marker (published before the helper reports its URL) and reuses it. Stale locks from a crashed reserver are stolen after the URL wait window, so a crash cannot wedge sign-in. - connect's sign-in block emitted the server-controlled OAuth authUrl as an OSC 8 hyperlink unconditionally. It now goes through the same isSafeLinkTarget scheme allowlist as every other server-supplied link; unsafe targets render as plain text. Validated: full validate green, per-file coverage thresholds met, live secure-add OAuth eval passing on both claude and copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/auth-helper.test.ts | 71 ++++++++++++++ .../__tests__/format-connection.test.ts | 34 +++++++ .../daemon-cli/src/connection/auth-helper.ts | 96 ++++++++++++++++++- .../src/connection/format-connection.ts | 7 +- 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts index d01a134195..c34cc6b83e 100644 --- a/clients/daemon-cli/__tests__/auth-helper.test.ts +++ b/clients/daemon-cli/__tests__/auth-helper.test.ts @@ -199,6 +199,77 @@ describe("auth-helper", () => { expect(url).toBe("https://as.example/after-noise"); }); + it("waits for a concurrent reserver's marker instead of spawning", async () => { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + // Publish the marker shortly after, as the winner's helper would. + const t = setTimeout(() => { + writeMarker({ + url: "https://as.example/authorize?state=winner", + pid: process.pid, + expiresAt: Date.now() + 60_000, + }); + }, 50); + try { + const url = await obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + // Would fail loudly if a spawn were attempted. + { + helperArgv1: path.join(dir, "does-not-exist.mjs"), + waitMs: 2_000, + pollMs: 10, + }, + ); + expect(url).toBe("https://as.example/authorize?state=winner"); + } finally { + clearTimeout(t); + } + }); + + it("times out with auth_required when the reserved flow never publishes", async () => { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + await expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { + helperArgv1: path.join(dir, "does-not-exist.mjs"), + waitMs: 60, + pollMs: 10, + }, + ), + ).rejects.toMatchObject({ + envelope: { code: "auth_required" }, + message: expect.stringContaining("in-progress sign-in"), + }); + }); + + it("steals a stale reservation and releases its own after the flow", async () => { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + // Backdate past the steal threshold (wait window + slack). + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockPath, old, old); + const script = writeHelperScript(` + process.stdin.resume(); + process.stdin.on("end", () => { + process.stdout.write( + JSON.stringify({ event: "auth_url", url: "https://as.example/stolen" }) + "\\n", + ); + }); + `); + const url = await obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { helperArgv1: script }, + ); + expect(url).toBe("https://as.example/stolen"); + // The reservation is released once the URL is obtained. + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("maps a helper spawn failure to auth_required", async () => { const originalExecPath = process.execPath; // A nonexistent interpreter makes spawn emit `error` instead of `exit`. diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index 73f938dc57..c8268891ca 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -728,6 +728,40 @@ describe("writeConnectionOutput", () => { expect(stdout).toContain("connections/show @api"); }); + it("connection authUrl: only allowlisted schemes become OSC 8 links", async () => { + const connection = { + name: "api", + serverIdentity: "https://mcp.example.com/mcp", + pendingAuth: true, + auth: { method: "oauth", authorized: false }, + }; + const style = createStyle(true); + stdout = ""; + await writeConnectionOutput( + { format: "text", style }, + { + kind: "connection", + connection, + authUrl: "https://as.example/authorize?state=ok", + }, + ); + expect(stdout).toContain("\u001b]8;;https://as.example/authorize?state=ok"); + + // Server-controlled OAuth metadata: an unsafe scheme renders as plain + // text, never a clickable link. + stdout = ""; + await writeConnectionOutput( + { format: "text", style }, + { + kind: "connection", + connection, + authUrl: "file:///etc/passwd", + }, + ); + expect(stdout).toContain("file:///etc/passwd"); + expect(stdout).not.toContain("\u001b]8"); + }); + it("connection without authUrl renders exactly as before (no sign-in block)", async () => { await writeConnectionOutput( { format: "text" }, diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts index d95bd906fd..ebd8b1c09f 100644 --- a/clients/daemon-cli/src/connection/auth-helper.ts +++ b/clients/daemon-cli/src/connection/auth-helper.ts @@ -208,11 +208,73 @@ export async function runAuthHelper(): Promise { } } +/** + * Atomically reserve the right to spawn the sign-in helper for one server. + * `wx` creation is the atomicity (O_EXCL also refuses a planted symlink); a + * leftover lock from a crashed reserver is stolen once it is older than the + * URL wait window, so a crash cannot wedge sign-in forever. + * + * @returns true if this process holds the reservation. + */ +function tryReserveAuthFlow(lockPath: string): boolean { + const create = () => + fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 }); + try { + create(); + return true; + } catch { + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + if (age > AUTH_URL_WAIT_MS + 5_000) { + fs.rmSync(lockPath, { force: true }); + create(); + return true; + } + } catch { + // Lock vanished or was recreated mid-check: treat as held by another. + } + return false; + } +} + +/** + * Another connect holds the flow reservation: wait for its helper to publish + * the marker and reuse that URL instead of spawning a competing helper. + */ +async function waitForPendingAuthUrl( + serverUrl: string, + waitMs: number, + pollMs: number, +): Promise { + const deadline = Date.now() + waitMs; + for (;;) { + const marker = readLivePendingAuthMarker(serverUrl); + if (marker !== undefined) return marker.url; + if (Date.now() >= deadline) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + "Timed out waiting for the in-progress sign-in flow to produce an authorization URL.", + { code: "auth_required" }, + ); + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, pollMs); + timer.unref(); + }); + } +} + /** * Non-TTY connect path: return the authorize URL for `serverConfig`, either * from a still-live pending marker (helper already waiting — reuse its URL) * or by spawning a fresh detached helper and reading the URL off its stdout. * + * The marker check and helper spawn are made atomic by a per-server lock + * file: concurrent connects for the same server would otherwise both pass + * the check and spawn helpers that contend for the OAuth callback port. The + * loser of the reservation waits for the winner's helper to publish the + * marker (written before the helper reports the URL) and reuses it. + * * After this resolves the helper is unrefed and survives this process: it * holds the loopback callback listener and completes the token exchange when * the user finishes signing in. @@ -220,16 +282,44 @@ export async function runAuthHelper(): Promise { export async function obtainPendingAuthUrl( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, - options?: { helperArgv1?: string }, + options?: { helperArgv1?: string; waitMs?: number; pollMs?: number }, ): Promise { + const waitMs = options?.waitMs ?? AUTH_URL_WAIT_MS; + let lockPath: string | undefined; const serverUrl = "url" in serverConfig ? serverConfig.url : undefined; if (serverUrl !== undefined) { const marker = readLivePendingAuthMarker(serverUrl); if (marker !== undefined) return marker.url; + lockPath = `${pendingAuthMarkerPath(serverUrl)}.lock`; + if (!tryReserveAuthFlow(lockPath)) { + return waitForPendingAuthUrl(serverUrl, waitMs, options?.pollMs ?? 250); + } } + try { + return await spawnAuthHelperForUrl( + serverConfig, + serverSettings, + waitMs, + options?.helperArgv1, + ); + } finally { + // Success: the helper's marker is already on disk (written before the + // URL event), so later connects reuse it. Failure: releasing lets the + // next attempt spawn a fresh helper. + if (lockPath !== undefined) fs.rmSync(lockPath, { force: true }); + } +} + +/** Spawn the detached helper and read the authorize URL off its stdout. */ +async function spawnAuthHelperForUrl( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, + waitMs: number, + helperArgv1?: string, +): Promise { /* v8 ignore next 6 -- argv[1] is always the mcpdo bin in production. */ - const script = options?.helperArgv1 ?? process.argv[1]; + const script = helperArgv1 ?? process.argv[1]; if (!script) { throw new CliExitCodeError( EXIT_CODES.USAGE, @@ -260,7 +350,7 @@ export async function obtainPendingAuthUrl( fail( "Timed out waiting for the sign-in helper to produce an authorization URL.", ); - }, AUTH_URL_WAIT_MS); + }, waitMs); timer.unref(); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 259844238e..5a4b226cdf 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -23,7 +23,7 @@ import { formatSkillVerifyListHuman, formatStreamEventHuman, } from "./format-human.js"; -import { sanitizeDeep, sanitizeText } from "./sanitize.js"; +import { isSafeLinkTarget, sanitizeDeep, sanitizeText } from "./sanitize.js"; import { PLAIN, type Style } from "@inspector/cli/style.js"; type JsonObject = Record; @@ -275,7 +275,10 @@ function humanPayload(payload: ConnectionWriteKind, style: Style): string { info, "", "Sign-in required. The user needs to open this link in a browser to authenticate:", - ` ${style.link(payload.authUrl)}`, + // The URL comes from server-controlled OAuth metadata: only + // allowlisted schemes become clickable OSC 8 links (same gate as + // every other server-supplied link — see sanitize.ts). + ` ${isSafeLinkTarget(payload.authUrl) ? style.link(payload.authUrl) : payload.authUrl}`, style.dim( `The connection completes automatically after sign-in — check with \`connections/show @${name}\`, or just run the next command.`, ), From d99996903df8597dd2a9c7581c6a3b23bfe163e5 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 11:40:32 -0700 Subject: [PATCH 55/69] fix(daemon-cli): keep auth wait timer ref'ed and make stale-lock steal atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review round 3 on the sign-in reservation code: - waitForPendingAuthUrl: don't unref the poll timer — for a reservation loser it can be the only live handle, and an unref'ed timer let Node exit mid-wait without ever printing the authorization URL. - readLivePendingAuthMarker: stop deleting stale/dead markers at read time; the unlink-by-pathname raced a just-spawned helper's fresh marker (TOCTOU). Stale markers are inert and writers replace them with rm+wx. - tryReserveAuthFlow: steal stale locks via an atomic rename-claim to a per-pid path so concurrent stealers cannot both win, with a post-rename staleness recheck. POSIX has no compare-and-delete; the rename makes the claim exclusive, which is what prevents double helper spawns. Tests updated for the no-delete-on-read semantics plus a claim- contention back-off case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/auth-helper.test.ts | 34 +++++++++++-- .../daemon-cli/src/connection/auth-helper.ts | 49 +++++++++++++------ 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts index c34cc6b83e..3e40572778 100644 --- a/clients/daemon-cli/__tests__/auth-helper.test.ts +++ b/clients/daemon-cli/__tests__/auth-helper.test.ts @@ -62,25 +62,25 @@ describe("auth-helper", () => { }); }); - it("removes and ignores an expired marker", () => { + it("ignores an expired marker without deleting it (writers replace it)", () => { const markerPath = writeMarker({ url: "https://as.example/authorize", pid: process.pid, expiresAt: Date.now() - 1, }); expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); - expect(fs.existsSync(markerPath)).toBe(false); + // Read-time deletion would race a just-spawned helper's fresh marker. + expect(fs.existsSync(markerPath)).toBe(true); }); - it("removes and ignores a marker whose helper process is gone", () => { - const markerPath = writeMarker({ + it("ignores a marker whose helper process is gone", () => { + writeMarker({ url: "https://as.example/authorize", // Out-of-range / nonexistent pid: process.kill(pid, 0) throws. pid: 0x7fffffff, expiresAt: Date.now() + 60_000, }); expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined(); - expect(fs.existsSync(markerPath)).toBe(false); }); it("ignores malformed marker files", () => { @@ -270,6 +270,30 @@ describe("auth-helper", () => { expect(fs.existsSync(lockPath)).toBe(false); }); + it("backs off when another stealer claims the stale lock first", async () => { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockPath, old, old); + // Occupying this process's claim path makes the rename throw — the + // same observable outcome as losing the claim race: reserve fails, + // the caller waits, and (with no marker forthcoming) times out. + fs.mkdirSync(`${lockPath}.claim-${process.pid}`); + await expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + { + helperArgv1: path.join(dir, "does-not-exist.mjs"), + waitMs: 60, + pollMs: 10, + }, + ), + ).rejects.toMatchObject({ envelope: { code: "auth_required" } }); + // The stale lock was claimed away by the rename attempt or left in + // place — either way this process never spawned a helper. + }); + it("maps a helper spawn failure to auth_required", async () => { const originalExecPath = process.execPath; // A nonexistent interpreter makes spawn emit `error` instead of `exit`. diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts index ebd8b1c09f..d5ffb98717 100644 --- a/clients/daemon-cli/src/connection/auth-helper.ts +++ b/clients/daemon-cli/src/connection/auth-helper.ts @@ -111,7 +111,11 @@ export function readLivePendingAuthMarker( } })(); if (!live) { - fs.rmSync(markerPath, { force: true }); + // Deliberately NOT deleted here: unlinking by pathname after the read + // would race a just-spawned helper replacing the marker (TOCTOU — the + // rm could delete the fresh URL). Stale markers are inert (re-validated + // on every read) and the next flow's writePendingAuthMarker replaces + // them. return undefined; } return marker; @@ -210,13 +214,20 @@ export async function runAuthHelper(): Promise { /** * Atomically reserve the right to spawn the sign-in helper for one server. - * `wx` creation is the atomicity (O_EXCL also refuses a planted symlink); a - * leftover lock from a crashed reserver is stolen once it is older than the - * URL wait window, so a crash cannot wedge sign-in forever. + * `wx` creation is the atomicity (O_EXCL also refuses a planted symlink). + * + * A leftover lock from a crashed reserver is stolen once it is older than + * the URL wait window. The steal claims the specific stale file by an + * atomic rename to a per-pid path — concurrent stealers cannot both win, + * and a winner that renamed a lock which turned out to be fresh backs off + * (POSIX has no compare-and-delete; the rename makes the claim itself + * exclusive, which is what prevents a double spawn). * * @returns true if this process holds the reservation. */ function tryReserveAuthFlow(lockPath: string): boolean { + const isStale = (mtimeMs: number) => + Date.now() - mtimeMs > AUTH_URL_WAIT_MS + 5_000; const create = () => fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 }); try { @@ -224,16 +235,24 @@ function tryReserveAuthFlow(lockPath: string): boolean { return true; } catch { try { - const age = Date.now() - fs.statSync(lockPath).mtimeMs; - if (age > AUTH_URL_WAIT_MS + 5_000) { - fs.rmSync(lockPath, { force: true }); - create(); - return true; - } + if (!isStale(fs.statSync(lockPath).mtimeMs)) return false; + // Claim the stale lock atomically: only one renamer succeeds. + const claimPath = `${lockPath}.claim-${process.pid}`; + fs.renameSync(lockPath, claimPath); + const claimedFresh = !isStale(fs.statSync(claimPath).mtimeMs); + fs.rmSync(claimPath, { force: true }); + // The claimed file was recreated fresh between stat and rename: an + // active reserver holds the flow — back off and wait for its marker. + // (Un-injectable microsecond race; the guard is what matters.) + /* v8 ignore next */ + if (claimedFresh) return false; + create(); + return true; } catch { - // Lock vanished or was recreated mid-check: treat as held by another. + // Lock vanished, was claimed by another stealer, or was recreated + // mid-steal: treat as held by another process. + return false; } - return false; } } @@ -258,8 +277,10 @@ async function waitForPendingAuthUrl( ); } await new Promise((resolve) => { - const timer = setTimeout(resolve, pollMs); - timer.unref(); + // NOT unref'ed: for a reservation loser this timer may be the only + // live handle, and an unref'ed one would let Node exit cleanly + // mid-wait — the connect would print no authorization URL at all. + setTimeout(resolve, pollMs); }); } } From a5576e341933a8f474c7d218f48cb439e5684839 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 12:14:26 -0700 Subject: [PATCH 56/69] fix(daemon-cli): pin default env to the caller; fail loud on transcript corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review round 4: - readTranscript (eval harness): a malformed record before the final line now throws with its line number instead of being silently dropped, matching the function's stated contract — only a torn final line (a kill artifact) is tolerated. - connect: snapshot the SDK's default-inherited environment (PATH, HOME, SHELL, ...) from the calling shell and merge it under any configured env before the config crosses to the daemon. The transport otherwise evaluates getDefaultEnvironment() inside the persistent daemon, handing servers the environment of whichever shell first spawned it. Extracted the cwd/command/env pinning into an exported pinStdioConfigToCaller, which now also treats a type-less config as stdio (stdio is the implicit default). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/pin-stdio-config.test.ts | 57 +++++++++++++++++ clients/daemon-cli/src/connection/mcp.ts | 61 +++++++++++-------- scripts/skill-eval-mcpdo.mjs | 28 ++++++--- scripts/skill-eval-mcpdo.test.mjs | 18 ++++++ 4 files changed, 130 insertions(+), 34 deletions(-) create mode 100644 clients/daemon-cli/__tests__/pin-stdio-config.test.ts diff --git a/clients/daemon-cli/__tests__/pin-stdio-config.test.ts b/clients/daemon-cli/__tests__/pin-stdio-config.test.ts new file mode 100644 index 0000000000..b4badadce6 --- /dev/null +++ b/clients/daemon-cli/__tests__/pin-stdio-config.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import * as path from "node:path"; +import { getDefaultEnvironment } from "@modelcontextprotocol/client/stdio"; +import type { StdioServerConfig } from "@inspector/core/mcp/types.js"; +import { pinStdioConfigToCaller } from "../src/connection/mcp.js"; + +type StdioConfig = StdioServerConfig; + +describe("pinStdioConfigToCaller", () => { + it("returns non-stdio configs unchanged", () => { + const config = { + type: "streamable-http", + url: "https://example.com/mcp", + } as const; + expect(pinStdioConfigToCaller(config)).toBe(config); + }); + + it("pins a missing cwd to the caller's cwd and resolves a relative one", () => { + const base: StdioConfig = { type: "stdio", command: process.execPath }; + expect(pinStdioConfigToCaller({ ...base }).cwd).toBe(process.cwd()); + expect(pinStdioConfigToCaller({ ...base, cwd: "sub/dir" }).cwd).toBe( + path.resolve("sub/dir"), + ); + }); + + it("treats a config without an explicit type as stdio", () => { + const pinned = pinStdioConfigToCaller({ + command: process.execPath, + }); + expect(pinned.cwd).toBe(process.cwd()); + }); + + it("snapshots the caller's default environment under the configured env", () => { + const pinned = pinStdioConfigToCaller({ + type: "stdio", + command: process.execPath, + env: { PATH: "/configured/bin", EXTRA: "1" }, + }); + const defaults: Record = getDefaultEnvironment(); + // Configured values win over the snapshot... + expect(pinned.env).toMatchObject({ PATH: "/configured/bin", EXTRA: "1" }); + // ...and every other default-inherited var is filled from THIS process, + // so the daemon's SDK transport never falls back to its own stale env. + for (const [key, value] of Object.entries(defaults)) { + if (key === "PATH") continue; + expect(pinned.env?.[key]).toBe(value); + } + }); + + it("resolves a bare command against the caller's PATH", () => { + const pinned = pinStdioConfigToCaller({ + type: "stdio", + command: "node", + }); + expect(path.isAbsolute(pinned.command)).toBe(true); + }); +}); diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index d343cf2de2..abef0dd5fc 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -19,6 +19,8 @@ import { selectServerEntry, } from "@inspector/core/mcp/node/index.js"; import { type LoggingLevel } from "@modelcontextprotocol/client"; +import { getDefaultEnvironment } from "@modelcontextprotocol/client/stdio"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import { LoggingLevelSchema } from "@modelcontextprotocol/core"; import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { callDaemon, ensureDaemon } from "../daemon/index.js"; @@ -215,6 +217,37 @@ export function expandConnAlias(argv: string[]): string[] { * IPC for connect/disconnect/connections and MCP RPCs; `servers/list` and * `servers/show` are local (no daemon). */ +/** + * Pin a stdio config's cwd, command, and environment to the CALLER's shell + * before it crosses the socket to the daemon. + * + * The daemon is a persistent detached process: it chdir()s at startup and + * keeps the environment of whichever mcpdo invocation first spawned it. Left + * unpinned, a relative cwd or bare command name — and every default-inherited + * env var the SDK transport fills in (PATH, HOME, SHELL, ...) — would resolve + * against that stale context instead of the shell that ran `connect`, which + * is what `mcpdo connect node ./server.js` means to the user. A cwd/env + * configured in the catalog entry (or flags) still wins; this only pins the + * defaults and resolves relative values. + */ +export function pinStdioConfigToCaller( + config: T, +): T { + // `type` is optional on stdio configs (stdio is the implicit default), so + // narrow by excluding the URL transports rather than matching "stdio". + if (config.type === "sse" || config.type === "streamable-http") return config; + const resolved = resolveCommandPath(config.command); + return { + ...config, + cwd: path.resolve(config.cwd ?? process.cwd()), + ...(resolved !== config.command ? { command: resolved } : {}), + // The SDK transport spawns with {...getDefaultEnvironment(), ...env} + // evaluated in the DAEMON process; snapshotting the same default set + // here makes those fallbacks the caller's. + env: { ...getDefaultEnvironment(), ...config.env }, + }; +} + export async function runMcp(argv?: string[]): Promise { const raw = argv ?? process.argv; const { argv: rewritten, connectionFromAt } = hoistAtConnection( @@ -526,30 +559,10 @@ function registerConnect(program: CommandType): void { const entries = await loadServerEntries(serverOptions); const selected = selectServerEntry(entries, selectName); - let serverConfig = selected.config; - // A stdio config with no cwd would resolve relative commands and - // relative paths against the DAEMON's cwd — whichever directory the - // first mcpdo invocation happened to run from. Pin it to the caller's - // cwd, which is what `mcpdo connect node ./server.js` means to the user. - // A cwd configured in the catalog/config entry (or --cwd) still wins — - // but a *relative* configured cwd must also be resolved here, against - // this shell's cwd, not left for the daemon to resolve post-chdir. - if (serverConfig.type === "stdio") { - serverConfig = { - ...serverConfig, - cwd: path.resolve(serverConfig.cwd ?? process.cwd()), - }; - } - // Same staleness problem for bare command names: the daemon would look - // `node` up in the PATH of whichever mcpdo invocation first spawned it. - // Resolve against the CALLER's PATH here so the daemon spawns exactly - // the binary this shell would have run. - if (serverConfig.type === "stdio") { - const resolved = resolveCommandPath(serverConfig.command); - if (resolved !== serverConfig.command) { - serverConfig = { ...serverConfig, command: resolved }; - } - } + // Pin cwd/command/env to THIS shell before the config crosses to the + // persistent daemon, whose own cwd and environment are stale (they + // belong to whichever invocation first spawned it). + const serverConfig = pinStdioConfigToCaller(selected.config); const serverSettings = withEmaOverride( withElicitOverride( withEraOverride( diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index dd8b59c19b..f5608a7964 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -456,16 +456,24 @@ export async function makeBehaviorEnv( /** Parse the shim transcript; tolerate a torn final line, never silent-drop. */ export function readTranscript(logPath) { if (!existsSync(logPath)) return []; - return readFileSync(logPath, "utf8") - .split("\n") - .filter((l) => l.trim() !== "") - .flatMap((l) => { - try { - return [JSON.parse(l)]; - } catch { - return []; - } - }); + const lines = readFileSync(logPath, "utf8").split("\n"); + const lastNonEmpty = lines.findLastIndex((l) => l.trim() !== ""); + const records = []; + for (let i = 0; i <= lastNonEmpty; i++) { + if (lines[i].trim() === "") continue; + try { + records.push(JSON.parse(lines[i])); + } catch { + // Only the final record can legitimately be malformed (a write torn + // by a kill); anything earlier is corruption the scorer must not + // silently misread as agent behavior. + if (i === lastNonEmpty) break; + throw new Error( + `${logPath}: malformed transcript record at line ${i + 1}`, + ); + } + } + return records; } /** diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs index be40d2dafc..8c89d17f1b 100644 --- a/scripts/skill-eval-mcpdo.test.mjs +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -305,6 +305,24 @@ test("readTranscript: missing file and torn tail line", () => { } }); +test("readTranscript: corruption before the final record throws", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "mcpdo-eval-test-")); + try { + const p = path.join(dir, "log.ndjson"); + const good = JSON.stringify({ argv: ["tools/list"], exit: 0, events: [] }); + // Only a torn FINAL line is a benign kill artifact; a malformed record + // with records after it is corruption the scorer must not misread as + // the agent never running that command. + writeFileSync(p, `${good}\n{"argv":["to\n${good}\n`); + assert.throws( + () => readTranscript(p), + /malformed transcript record at line 2/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("consent clicker: finds an authorize URL split across stream chunks", async () => { const { createServer } = await import("node:http"); const hits = { callback: 0 }; From c3e88231cd3f9c8cb68426e45b32d823b0b4a7f8 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 13:07:16 -0700 Subject: [PATCH 57/69] fix(daemon-cli): JSON Schema code-point lengths, fixture ticket hashing, spec doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review round 5: - form-schema/form-prompt: minLength/maxLength are measured in Unicode code points per JSON Schema, not UTF-16 units — a valid astral-char default (or answer) was rejected / trapped in the re-prompt loop. Shared codePointLength() applied at all four bound checks. - test-server-fixtures: submit_ticket ids now hash the full submission (djb2) instead of summary/email lengths, which collided whenever only contact_name changed; comment made honest about the 4-digit space. - specification/v2_cli_tui_launcher.md: 'Shared core consumption' and the summary now count daemon-cli among the core consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../daemon-cli/__tests__/form-prompt.test.ts | 8 ++++++++ .../daemon-cli/__tests__/form-schema.test.ts | 17 +++++++++++++++++ .../daemon-cli/src/connection/form-prompt.ts | 7 +++++-- .../daemon-cli/src/connection/form-schema.ts | 14 ++++++++++++-- specification/v2_cli_tui_launcher.md | 10 +++++----- test-servers/src/test-server-fixtures.ts | 16 +++++++++++++--- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts index 0b6e773016..768f878395 100644 --- a/clients/daemon-cli/__tests__/form-prompt.test.ts +++ b/clients/daemon-cli/__tests__/form-prompt.test.ts @@ -143,6 +143,14 @@ describe("promptForm", () => { expect(stderr).toContain("at most 5"); }); + it("measures length bounds in code points, not UTF-16 units", async () => { + // "😀😀😀" is 3 code points (6 UTF-16 units): valid for min 3 / max 5. + const field: FormField = { ...stringField, minLength: 3, maxLength: 5 }; + const rl = fakeRl(["😀😀😀", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "😀😀😀" } }); + }); + it("collects a required number field with range validation", async () => { const field: FormField = { name: "age", diff --git a/clients/daemon-cli/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts index a4baacdc14..4a554b7af6 100644 --- a/clients/daemon-cli/__tests__/form-schema.test.ts +++ b/clients/daemon-cli/__tests__/form-schema.test.ts @@ -366,6 +366,23 @@ describe("parseFormSchema", () => { ).toHaveLength(1); }); + it("measures default length bounds in code points, not UTF-16 units", () => { + // "😀" is 1 code point (2 UTF-16 units): a valid default for maxLength 1. + expect( + parseFormSchema({ + type: "object", + properties: { s: { type: "string", maxLength: 1, default: "😀" } }, + }), + ).toHaveLength(1); + // ...and 1 code point still violates minLength 2. + expect( + parseFormSchema({ + type: "object", + properties: { s: { type: "string", minLength: 2, default: "😀" } }, + }), + ).toBeNull(); + }); + it("returns null for defaults that violate the field's own constraints", () => { const cases: Record[] = [ { n: { type: "number", minimum: 1, maximum: 10, default: 11 } }, diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index 9a49304eda..0a469da5a1 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -9,6 +9,7 @@ import type { Interface as ReadlineInterface } from "node:readline/promises"; import type { Style } from "@inspector/cli/style.js"; import type { FormField } from "./form-schema.js"; +import { codePointLength } from "./form-schema.js"; import { sanitizeText } from "./sanitize.js"; export type FormOutcome = @@ -207,13 +208,15 @@ async function promptField( // non-empty — so minLength (if any) decides below. if (raw === "" && !field.required) return undefined; const value = raw; - if (field.minLength !== undefined && value.length < field.minLength) { + // Code points, not UTF-16 units: JSON Schema length semantics. + const length = codePointLength(value); + if (field.minLength !== undefined && length < field.minLength) { process.stderr.write( style.red(` Must be at least ${field.minLength} characters.\n`), ); continue; } - if (field.maxLength !== undefined && value.length > field.maxLength) { + if (field.maxLength !== undefined && length > field.maxLength) { process.stderr.write( style.red(` Must be at most ${field.maxLength} characters.\n`), ); diff --git a/clients/daemon-cli/src/connection/form-schema.ts b/clients/daemon-cli/src/connection/form-schema.ts index 87632c51cb..5f807e6113 100644 --- a/clients/daemon-cli/src/connection/form-schema.ts +++ b/clients/daemon-cli/src/connection/form-schema.ts @@ -86,6 +86,16 @@ function isValidCount(value: number | undefined): boolean { return value === undefined || (Number.isInteger(value) && value >= 0); } +/** + * JSON Schema `minLength`/`maxLength` count Unicode code points, not UTF-16 + * code units — `"😀"` has length 1 under the spec but `.length === 2` in + * JavaScript. Every bound check on user-visible strings must use this. + */ +export function codePointLength(value: string): number { + // String iteration yields code points, unlike .length's UTF-16 units. + return [...value].length; +} + /** * A structurally valid field can still be internally inconsistent — * unsatisfiable constraints (`minimum > maximum`, `minItems` above the @@ -127,13 +137,13 @@ function isConsistent(field: FieldExtra): boolean { if (field.default !== undefined) { if ( field.minLength !== undefined && - field.default.length < field.minLength + codePointLength(field.default) < field.minLength ) { return false; } if ( field.maxLength !== undefined && - field.default.length > field.maxLength + codePointLength(field.default) > field.maxLength ) { return false; } diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 03bb15d5eb..dc107fe210 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -6,7 +6,7 @@ ## Summary -v2 ships three non-web Inspector incarnations alongside the web client: a **one-shot CLI**, an **interactive TUI**, and a **launcher** that routes to web, CLI, or TUI from a single `mcp-inspector` binary. All three consume the same `core/` source as the web client via the `@inspector/core` path alias and run on the shared `InspectorClient` stack ported from v1.5/main. +v2 ships four non-web Inspector incarnations alongside the web client: a **one-shot CLI**, an **interactive TUI**, an experimental **connection CLI** (`mcpdo`, `clients/daemon-cli/`), and a **launcher** that routes to web, CLI, or TUI from a single `mcp-inspector` binary. All four consume the same `core/` source as the web client via the `@inspector/core` path alias and run on the shared `InspectorClient` stack ported from v1.5/main. This document describes how those clients are built, wired, and tested today, and records known gaps. For catalog vs launch-time config semantics (`--config`, `--catalog`, import), see [Catalog and Launch Configuration](v2_catalog_launch_config.md). @@ -72,22 +72,22 @@ Root scripts `inspector`, `web`, and `web:dev` are thin wrappers around the laun ## Shared core consumption -All three clients import from `@inspector/core/...` (mapped to `../../core/` source). +All four clients import from `@inspector/core/...` (mapped to `../../core/` source). -| Concern | Web | CLI / TUI | Launcher | +| Concern | Web | CLI / daemon-cli / TUI | Launcher | | -------------- | --------------------------------- | -------------------------------------------------------- | ------------------------- | | Dev typecheck | `tsconfig.app.json` paths | per-client `tsconfig.json` paths | `tsconfig.json` (no core) | | Runtime bundle | Vite alias | tsup `noExternal: [/^@inspector\/core/]` + esbuild alias | n/a | | Tests | Vitest projects in `clients/web/` | Vitest + `vitest.shared.mts` aliases | none | -`vitest.shared.mts` at repo root centralizes `@inspector/core` and test-server aliases plus bare-module pins (`react`, `pino`, SDK, etc.) so CLI/TUI Vitest configs stay aligned with web. +`vitest.shared.mts` at repo root centralizes `@inspector/core` and test-server aliases plus bare-module pins (`react`, `pino`, SDK, etc.) so CLI/daemon-cli/TUI Vitest configs stay aligned with web. **Resolved design choices:** | Topic | Decision | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Core package | No separate `inspector-core` npm package; source-only `core/` | -| CLI/TUI build | tsup bundles `@inspector/core` into `build/index.js` | +| CLI/TUI build | tsup bundles `@inspector/core` into each client's `build/` (CLI/TUI `index.js`; daemon-cli `mcp-bin.js` + `daemon.js`) | | Core tests | Not duplicated under cli/tui; web unit + integration suites cover `core/` | | Default config path | `loadServerEntries()` applies `withDefaultCatalogPath()` → `~/.mcp-inspector/mcp.json` when no `--catalog`/`--config` and no ad-hoc target | diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index e94cca05f5..3fe73a35c2 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -546,9 +546,19 @@ export function createSubmitTicketTool(): ToolDefinition { ); } const content = (result.content ?? {}) as Record; - // Deterministic-looking but content-derived id, so distinct submissions - // get distinct numbers without the fixture holding state. - const ticket = `TCK-${(1000 + ((summary.length * 37 + String(content.contact_email).length * 101) % 9000)).toString()}`; + // Content-derived id (djb2 over the full submission), so the fixture + // holds no state and distinct submissions get distinct numbers except + // for genuine hash collisions in the 4-digit space. + const payload = JSON.stringify([ + summary, + content.contact_name, + content.contact_email, + ]); + let hash = 5381; + for (let i = 0; i < payload.length; i++) { + hash = ((hash * 33) ^ payload.charCodeAt(i)) >>> 0; + } + const ticket = `TCK-${(1000 + (hash % 9000)).toString()}`; return toToolResult( JSON.stringify({ ticket, From 548826ccc9eeeea3324396e5a05ac796660c7169 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 13:24:35 -0700 Subject: [PATCH 58/69] fix(cli, daemon-cli): buffer early subscribe updates; ownership-safe marker cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review round 6: - resources/subscribe: the resourceUpdated listener now attaches before the subscribe handshake and buffers matching updates until the consumer's start() — a server notifying immediately after (or with) its subscribe response no longer loses that update in the window before the stream starts. The subscribe-failure path detaches the listener; ipc-glue already guarantees every stream outcome is started and stopped, so no leak on vanished callers. - auth helper exit: marker cleanup now verifies the on-disk marker's pid is this helper's before deleting (removeOwnPendingAuthMarker) — past the 15-minute TTL a replacement flow's fresh marker at the same pathname would otherwise be deleted out from under its callers. The residual read-to-rm window is documented; losing it costs one extra sign-in prompt, never a wrong URL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../cli/__tests__/run-method-mocks.test.ts | 55 +++++++++++++++++++ clients/cli/src/handlers/run-method.ts | 36 ++++++++---- .../daemon-cli/__tests__/auth-helper.test.ts | 25 +++++++++ .../daemon-cli/src/connection/auth-helper.ts | 23 +++++++- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index cd982c316b..437f9515a5 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -108,6 +108,61 @@ describe("runMethod (mocked client)", () => { expect(failing.unsubscribeFromResource).toHaveBeenCalledTimes(1); }); + it("buffers updates that land between subscribe and start", async () => { + const listeners = new Set<(ev: Event) => void>(); + const client = mockClient({ + addEventListener: vi.fn((_type: string, fn: (ev: Event) => void) => + listeners.add(fn), + ), + removeEventListener: vi.fn((_type: string, fn: (ev: Event) => void) => + listeners.delete(fn), + ), + } as unknown as Partial); + const outcome = await runMethod(client, { + method: "resources/subscribe", + uri: "test://early", + }); + // The listener is live before any consumer starts the stream... + expect(listeners.size).toBe(1); + // ...so an update in the subscribe→start window is captured, not lost. + const dispatch = (uri: string) => { + for (const fn of listeners) + fn(new CustomEvent("resourceUpdated", { detail: { uri } })); + }; + dispatch("test://early"); + dispatch("test://other"); // different URI: filtered out + const lines: unknown[] = []; + expect(outcome.kind).toBe("stream"); + if (outcome.kind !== "stream") return; + const stop = outcome.start((obj) => lines.push(obj)); + expect(lines).toEqual([ + { type: "subscribed", uri: "test://early" }, + { type: "resources/updated", uri: "test://early" }, + ]); + // Post-start events flow straight through. + dispatch("test://early"); + expect(lines).toHaveLength(3); + stop(); + expect(listeners.size).toBe(0); + }); + + it("detaches the early listener when the subscribe fails", async () => { + const listeners = new Set<(ev: Event) => void>(); + const client = mockClient({ + addEventListener: vi.fn((_type: string, fn: (ev: Event) => void) => + listeners.add(fn), + ), + removeEventListener: vi.fn((_type: string, fn: (ev: Event) => void) => + listeners.delete(fn), + ), + subscribeToResource: vi.fn().mockRejectedValue(new Error("nope")), + } as unknown as Partial); + await expect( + runMethod(client, { method: "resources/subscribe", uri: "test://f" }), + ).rejects.toThrow("nope"); + expect(listeners.size).toBe(0); + }); + it("concurrent same-URI subscribes share one in-flight subscription", async () => { let release!: () => void; const gate = new Promise((resolve) => (release = resolve)); diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index b3ff0ac2e9..7333adddaf 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -252,9 +252,30 @@ export async function runMethod( } const entry = shared; entry.count++; + // Attached BEFORE the subscribe handshake completes: a server may + // notify immediately after (or with) its subscribe response, and the + // stream's consumer only calls start() after this outcome crosses + // back through dispatch. Updates landing in that window are buffered + // and flushed to the first writeLine; ipc-glue guarantees every + // stream outcome is started (inert-started on a vanished caller), so + // stop() below always detaches this listener. + const buffered: Array<{ type: string; uri: string }> = []; + let sink: ((obj: unknown) => void) | undefined; + const onUpdate = (ev: Event) => { + const detail = (ev as CustomEvent<{ uri: string }>).detail; + // Multiple subscribe streams can share one connection; only + // forward updates for this stream's URI. Events without a uri + // (spec-noncompliant server) still pass through as before. + if (detail?.uri !== undefined && detail.uri !== uri) return; + const line = { type: "resources/updated", uri: detail?.uri ?? uri }; + if (sink) sink(line); + else buffered.push(line); + }; + inspectorClient.addEventListener("resourceUpdated", onUpdate); try { await entry.ready; } catch (error) { + inspectorClient.removeEventListener("resourceUpdated", onUpdate); entry.count--; if (entry.count === 0 && refs.get(uri) === entry) refs.delete(uri); throw error; @@ -264,18 +285,9 @@ export async function runMethod( label: "resources/subscribe", start: (writeLine) => { writeLine({ type: "subscribed", uri: args.uri }); - const onUpdate = (ev: Event) => { - const detail = (ev as CustomEvent<{ uri: string }>).detail; - // Multiple subscribe streams can share one connection; only - // forward updates for this stream's URI. Events without a uri - // (spec-noncompliant server) still pass through as before. - if (detail?.uri !== undefined && detail.uri !== args.uri) return; - writeLine({ - type: "resources/updated", - uri: detail?.uri ?? args.uri, - }); - }; - inspectorClient.addEventListener("resourceUpdated", onUpdate); + for (const line of buffered) writeLine(line); + buffered.length = 0; + sink = writeLine; let closed = false; return () => { // A second stop from any caller must not double-decrement the diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts index 3e40572778..e4368c5d0b 100644 --- a/clients/daemon-cli/__tests__/auth-helper.test.ts +++ b/clients/daemon-cli/__tests__/auth-helper.test.ts @@ -16,6 +16,7 @@ import { obtainPendingAuthUrl, pendingAuthMarkerPath, readLivePendingAuthMarker, + removeOwnPendingAuthMarker, runAuthHelper, type PendingAuthMarker, } from "../src/connection/auth-helper.js"; @@ -73,6 +74,30 @@ describe("auth-helper", () => { expect(fs.existsSync(markerPath)).toBe(true); }); + it("cleanup removes only this process's own marker", () => { + const markerPath = writeMarker({ + url: "https://as.example/authorize", + pid: process.pid, + expiresAt: Date.now() + 60_000, + }); + removeOwnPendingAuthMarker(markerPath); + expect(fs.existsSync(markerPath)).toBe(false); + // A replacement flow's marker (different pid) must survive the old + // helper's exit cleanup. + writeMarker({ + url: "https://as.example/authorize-2", + pid: process.pid + 1, + expiresAt: Date.now() + 60_000, + }); + removeOwnPendingAuthMarker(markerPath); + expect(fs.existsSync(markerPath)).toBe(true); + // Missing or malformed markers are a no-op, not an error. + fs.writeFileSync(markerPath, "not json\n"); + expect(() => removeOwnPendingAuthMarker(markerPath)).not.toThrow(); + fs.rmSync(markerPath, { force: true }); + expect(() => removeOwnPendingAuthMarker(markerPath)).not.toThrow(); + }); + it("ignores a marker whose helper process is gone", () => { writeMarker({ url: "https://as.example/authorize", diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts index d5ffb98717..e9132ea628 100644 --- a/clients/daemon-cli/src/connection/auth-helper.ts +++ b/clients/daemon-cli/src/connection/auth-helper.ts @@ -121,6 +121,25 @@ export function readLivePendingAuthMarker( return marker; } +/** + * Remove the pending-auth marker only if THIS process wrote the one on disk. + * Past the 15-minute TTL a replacement flow may have published a fresh + * marker at the same shared pathname, and an unconditional rm at helper exit + * would delete the replacement's URL out from under its callers. A read→rm + * microsecond window remains (POSIX has no compare-and-delete); losing it + * costs one extra sign-in prompt, never a wrong URL. + */ +export function removeOwnPendingAuthMarker(markerPath: string) { + try { + const onDisk = JSON.parse( + fs.readFileSync(markerPath, "utf8"), + ) as PendingAuthMarker; + if (onDisk.pid === process.pid) fs.rmSync(markerPath, { force: true }); + } catch { + // Missing or unreadable marker: nothing of ours to clean up. + } +} + function writePendingAuthMarker(markerPath: string, marker: PendingAuthMarker) { // Recreate exclusively (same symlink hardening as the daemon log): an // append/overwrite open would follow a planted symlink and only apply the @@ -206,9 +225,7 @@ export async function runAuthHelper(): Promise { }); throw error; } finally { - if (markerPath !== undefined) { - fs.rmSync(markerPath, { force: true }); - } + if (markerPath !== undefined) removeOwnPendingAuthMarker(markerPath); } } From c385f1d5fe627f46f94c98764c56c94190cca510 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 14:14:08 -0700 Subject: [PATCH 59/69] docs(daemon-cli): describe parked elicitation instead of the former auto-decline The README still said --format json auto-declines forms and every other non-TTY caller gets a real prompt. Actual behavior since the parking change: non-interactive callers (--format json or no TTY) get the elicitation parked, the RPC returns an elicitationPending payload, and the answer comes via elicitation/respond (auto-cancel after 10 minutes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/README.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md index 3b132c3aec..7a06c9099b 100644 --- a/clients/daemon-cli/README.md +++ b/clients/daemon-cli/README.md @@ -160,19 +160,24 @@ legacy server→client `elicitation/create` requests and modern non-task MRTR - **Form mode**: mcpdo renders one prompt per field from the schema, with a review step (edit any field again, or submit) before answering. -Only `--format json` callers get an automatic decline (URL mode: cancel) -instead of a prompt. - -> **Decision — who answers a prompt.** Only `--format json` auto-declines -> (its stdout must stay a single machine-readable payload). Everything else — -> including a plain non-TTY stdin — gets a real prompt, which means an agent -> driving mcpdo can routinely read a form-mode question and answer on the -> user's behalf. That is deliberate for an inspector tool. URL-mode is -> different: there is never an auto-accept — completion is only ever -> confirmed by an explicit answer to the prompt, because the out-of-band -> action (typically an auth or consent step in a browser) is the user's to -> perform. Use `--elicit off` on `connect` to keep any elicitation from -> being asked at all. +Interactive callers (`--format text` on a TTY) get these prompts inline. +Non-interactive callers — `--format json`, or no TTY at all — don't get a +prompt: the daemon **parks** the elicitation and the RPC returns an +`elicitationPending` payload naming the pending id. Answer it (from any +shell) with `elicitation/respond ` — form answers as `key:=value` pairs +or JSON, `--done` for URL mode, or `--decline` / `--cancel` — after which the +original call completes. An unanswered parked elicitation is auto-cancelled +after 10 minutes. + +> **Decision — who answers a prompt.** Non-interactive callers never get an +> automatic decline: the elicitation is parked so whoever drives mcpdo (a +> script, an agent relaying to a human) can answer deliberately via +> `elicitation/respond`, on its own schedule. That is deliberate for an +> inspector tool. URL-mode is different: there is never an auto-accept — +> completion is only ever confirmed by an explicit answer, because the +> out-of-band action (typically an auth or consent step in a browser) is the +> user's to perform. Use `--elicit off` on `connect` to keep any elicitation +> from being asked at all. By default mcpdo advertises **both** modes to the server (`elicit: {url, form}`), matching pre-#1783 behavior. Override this per connection with @@ -181,8 +186,8 @@ form}`), matching pre-#1783 behavior. Override this per connection with - `off` — advertise no elicitation capability at all. Useful when whatever is driving mcpdo (a script, an agent) can't handle an interactive prompt itself — omitting the capability lets a well-behaved server fall back to its own - alternative (e.g. proceeding with defaults) instead of the request being - auto-declined. + alternative (e.g. proceeding with defaults) instead of the request sitting + parked until someone answers it. - `url` — URL mode only. - `form` — form mode only. - `both` — the default; both modes. From c4e713ab6a95b6ab16cb4056f7f2e0ce53d3b907 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 29 Sep 2026 17:35:31 -0700 Subject: [PATCH 60/69] mcpdo: connections/show completes a finished out-of-band sign-in; list/use annotate progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-TTY connect hands OAuth to the detached helper and registers a pending-auth entry, but connections/show never completed it: pollers following connect's own "check with connections/show" guidance saw "Sign-in: pending" forever (with the Auth line contradictorily flipped to authorized), until some real op revived the entry. - connections/show: when the entry is pendingAuth and the disk tokens are usable, run the same revive the first op would — show now observes (and performs) the completion. Revive failure falls back to the honest pending snapshot; a raced disconnect surfaces as the usual unknown-connection error. - connections/list / connections/use / daemon/status stay read-only (no dial) but annotate pending entries whose sign-in finished: pendingAuthSignedIn: true, live auth, and "signed in — completing on next use" in human output, so a poller knows the user's part is done. - Docs: README auth blurb, SKILL.md auth section, protocol/mcp comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/README.md | 2 +- .../__tests__/daemon-connections.test.ts | 212 +++++++++++++++++- .../__tests__/format-connection.test.ts | 49 ++++ .../daemon-cli/src/connection/format-human.ts | 16 +- clients/daemon-cli/src/connection/mcp.ts | 5 +- clients/daemon-cli/src/daemon/connections.ts | 25 +++ clients/daemon-cli/src/daemon/protocol.ts | 12 +- clients/daemon-cli/src/daemon/server.ts | 52 ++++- skills/mcpdo/SKILL.md | 4 + 9 files changed, 362 insertions(+), 15 deletions(-) diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md index 7a06c9099b..042c2daaa2 100644 --- a/clients/daemon-cli/README.md +++ b/clients/daemon-cli/README.md @@ -89,7 +89,7 @@ mcpdo tools/list **Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope. -**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). +**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). Non-TTY `connect` exits 0 with `pendingAuth: true` and an `authUrl` to relay; after the user signs in, any real command completes the connection, `connections/show` completes it too, and `connections/list` marks the entry `pendingAuthSignedIn` ("signed in — completing on next use") without dialing. See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 71854cf62f..8f8fe727ba 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -514,6 +514,214 @@ describe("ConnectionRegistry", () => { } }); + it("connections/show completes a pending-auth entry once signed-in tokens are on disk", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const { NodeOAuthStorage, resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + const serverUrl = "https://mcp.example.com/mcp"; + // Isolated client.json + oauth.json so the show handler's disk reads are + // deterministic (same pattern as the show-recomputes-from-disk test). + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mcp-show-pending-"), + ); + const savedEnv = { + MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, + MCP_INSPECTOR_OAUTH_STATE_PATH: + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH, + }; + process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json"); + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join( + stateDir, + "oauth.json", + ); + resetNodeOAuthStorageCache(); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + // Dial at connect time: no stored tokens yet — auth_required. + .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 })) + // Revive triggered by connections/show after tokens land: succeeds. + .mockResolvedValueOnce(undefined); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + let pendingClient: unknown; + const statusSpy = vi + .spyOn(InspectorClient.prototype, "getStatus") + .mockImplementation(function (this: unknown) { + return this === pendingClient ? "disconnected" : "connected"; + }); + const server = new DaemonServer({ + dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcp-show-pending-daemon-")), + idleMs: 0, + }); + try { + await server.registry.connect({ + name: "p", + serverConfig: { type: "streamable-http", url: serverUrl }, + serverIdentity: serverUrl, + pendingOnAuthRequired: true, + }); + pendingClient = server.registry.clientFor("p", false); + + // Before sign-in completes, show reports the pending snapshot (no + // usable tokens on disk → no revive attempt). + const before = await server.handle({ + id: "s1", + op: "connections/show", + params: { name: "p" }, + }); + expect(before.ok).toBe(true); + if (!before.ok) throw new Error("unreachable"); + expect(before.result).toMatchObject({ + pendingAuth: true, + transport: "dormant", + }); + + // The detached helper finishes sign-in: usable tokens land on disk. + await new NodeOAuthStorage().saveTokens(serverUrl, { + access_token: "opaque-access-token", + token_type: "Bearer", + }); + resetNodeOAuthStorageCache(); + + // Now show itself completes the connection via revive. + const after = await server.handle({ + id: "s2", + op: "connections/show", + params: { name: "p" }, + }); + expect(after.ok).toBe(true); + if (!after.ok) throw new Error("unreachable"); + expect( + (after.result as { pendingAuth?: boolean }).pendingAuth, + ).toBeUndefined(); + expect(after.result).toMatchObject({ + transport: "live", + auth: { method: "oauth", authorized: true }, + }); + expect(server.registry.clientFor("p", false)).not.toBe(pendingClient); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + statusSpy.mockRestore(); + process.env.MCP_CLIENT_CONFIG_PATH = savedEnv.MCP_CLIENT_CONFIG_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = + savedEnv.MCP_INSPECTOR_OAUTH_STATE_PATH; + resetNodeOAuthStorageCache(); + await server.stop().catch(() => {}); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("connections/show keeps the pending snapshot when the revive attempt fails", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const { NodeOAuthStorage, resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + const serverUrl = "https://mcp.example.com/mcp"; + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mcp-show-pending-fail-"), + ); + const savedEnv = { + MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH, + MCP_INSPECTOR_OAUTH_STATE_PATH: + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH, + }; + process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json"); + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join( + stateDir, + "oauth.json", + ); + resetNodeOAuthStorageCache(); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + // Both the original dial and the show-triggered revive fail. + .mockRejectedValue(Object.assign(new Error("boom"), { status: 401 })); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const statusSpy = vi + .spyOn(InspectorClient.prototype, "getStatus") + .mockReturnValue("disconnected"); + const server = new DaemonServer({ + dir: fs.mkdtempSync( + path.join(os.tmpdir(), "mcp-show-pending-fail-daemon-"), + ), + idleMs: 0, + }); + try { + await server.registry.connect({ + name: "p", + serverConfig: { type: "streamable-http", url: serverUrl }, + serverIdentity: serverUrl, + pendingOnAuthRequired: true, + }); + await new NodeOAuthStorage().saveTokens(serverUrl, { + access_token: "opaque-access-token", + token_type: "Bearer", + }); + resetNodeOAuthStorageCache(); + + // Revive fails (tokens rejected on dial): show still answers with the + // honest pending snapshot instead of erroring, and the entry survives + // so a later op retries. + const shown = await server.handle({ + id: "s1", + op: "connections/show", + params: { name: "p" }, + }); + expect(shown.ok).toBe(true); + if (!shown.ok) throw new Error("unreachable"); + expect(shown.result).toMatchObject({ + pendingAuth: true, + transport: "dormant", + }); + expect(server.registry.connectionCount()).toBe(1); + + // The read-only echoes annotate instead of dialing: tokens are on + // disk, so list/use report signed-in progress while the entry stays + // pending. + const listed = await server.handle({ + id: "l1", + op: "connections/list", + params: {}, + }); + expect(listed.ok).toBe(true); + if (!listed.ok) throw new Error("unreachable"); + expect( + (listed.result as { connections: unknown[] }).connections[0], + ).toMatchObject({ + pendingAuth: true, + pendingAuthSignedIn: true, + auth: { method: "oauth", authorized: true }, + }); + const used = await server.handle({ + id: "u1", + op: "connections/use", + params: { name: "p" }, + }); + expect(used.ok).toBe(true); + if (!used.ok) throw new Error("unreachable"); + expect(used.result).toMatchObject({ + pendingAuth: true, + pendingAuthSignedIn: true, + }); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + statusSpy.mockRestore(); + process.env.MCP_CLIENT_CONFIG_PATH = savedEnv.MCP_CLIENT_CONFIG_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = + savedEnv.MCP_INSPECTOR_OAUTH_STATE_PATH; + resetNodeOAuthStorageCache(); + await server.stop().catch(() => {}); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("a connect that outlives shutdown's quiesce grace tears its client down instead of leaking it", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); let releaseConnect!: () => void; @@ -1047,7 +1255,9 @@ describe("DaemonServer IPC", () => { .registry; await registry.clientFor("stdio", false).disconnect(); - // connections/show is passive: it reports the drop, no revive. + // connections/show is passive for a non-pending entry: it reports the + // drop, no revive (out-of-band sign-in completion is the one case where + // show itself revives — covered separately). const shown = await callDaemon<{ transport?: string }>( "connections/show", { name: "stdio" }, diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index c8268891ca..59dddccc52 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -728,6 +728,55 @@ describe("writeConnectionOutput", () => { expect(stdout).toContain("connections/show @api"); }); + it("pendingAuthSignedIn: human output flips to completed / completing-on-next-use", async () => { + stdout = ""; + await writeConnectionOutput( + { format: "text" }, + { + kind: "connection", + connection: { + name: "api", + serverIdentity: "https://mcp.example.com/mcp", + pendingAuth: true, + pendingAuthSignedIn: true, + auth: { method: "oauth", authorized: true }, + }, + }, + ); + expect(stdout).toContain("Sign-in: completed"); + expect(stdout).toContain("finishes on next use"); + expect(stdout).not.toContain("Sign-in: pending"); + + stdout = ""; + await writeConnectionOutput( + { format: "text" }, + { + kind: "connections/list", + connections: [ + { + name: "api", + serverIdentity: "https://mcp.example.com/mcp", + connectedAt: 1, + lastAccessedAt: 1, + isMru: true, + pendingAuth: true, + pendingAuthSignedIn: true, + }, + { + name: "other", + serverIdentity: "https://mcp2.example.com/mcp", + connectedAt: 1, + lastAccessedAt: 1, + isMru: false, + pendingAuth: true, + }, + ], + }, + ); + expect(stdout).toContain("signed in — completing on next use"); + expect(stdout).toContain("(sign-in pending)"); + }); + it("connection authUrl: only allowlisted schemes become OSC 8 links", async () => { const connection = { name: "api", diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 3411abfdb6..03d5bba783 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -674,8 +674,14 @@ export function formatConnectionsListHuman( s.protocolEra !== undefined ? style.dim(` [${String(s.protocolEra)}]`) : ""; + const pending = + s.pendingAuth === true + ? s.pendingAuthSignedIn === true + ? style.yellow(" (signed in — completing on next use)") + : style.yellow(" (sign-in pending)") + : ""; lines.push( - `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}${s.pendingAuth === true ? style.yellow(" (sign-in pending)") : ""}`, + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}${pending}`, ); } if (connections.length === 0) lines.push(style.dim("(none — connect first)")); @@ -723,10 +729,14 @@ export function formatConnectionInfoHuman( lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`); } // Sign-in pending (non-TTY connect handed OAuth to the detached helper): - // the connection completes automatically on first use after sign-in. + // the connection completes automatically on first use after sign-in. Once + // the tokens are on disk the read-only echoes flag it, so a poller knows + // the user's part is done. if (connection.pendingAuth === true) { lines.push( - `Sign-in: ${style.yellow("pending")} ${style.dim("(completes automatically after the user signs in)")}`, + connection.pendingAuthSignedIn === true + ? `Sign-in: ${style.green("completed")} ${style.dim("(connection finishes on next use)")}` + : `Sign-in: ${style.yellow("pending")} ${style.dim("(completes automatically after the user signs in)")}`, ); } // Live transport state (`connections/show` only). Dormant is informational: diff --git a/clients/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts index abef0dd5fc..6170895485 100644 --- a/clients/daemon-cli/src/connection/mcp.ts +++ b/clients/daemon-cli/src/connection/mcp.ts @@ -626,8 +626,9 @@ function registerConnect(program: CommandType): void { ); // The dial re-attempt is cheap (it fails auth_required again) but // makes the daemon register the pending entry, so - // `connections/show @name` polls sign-in state and the first real - // op completes the connection via revive. + // `connections/show @name` polls sign-in state (completing the + // connection itself once tokens land) and any real op completes it + // via revive. const { socketPath: pendingSocketPath } = await ensureDaemon(); const pending = await callDaemon( "connect", diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index c5c5d14ff0..e8b454c94b 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -160,6 +160,31 @@ export class ConnectionRegistry { .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); } + /** + * Annotate pending-auth entries whose out-of-band sign-in has completed: + * a disk check only (no dial, no MRU touch), setting + * {@link ConnectionInfo.pendingAuthSignedIn} and refreshing `auth` to the + * live disk state so the echo isn't the contradictory "pending + + * authorized: false". Used by the read-only echoes (`connections/list`, + * `connections/use`, `daemon/status`); `connections/show` goes further and + * revives (see the show handler). + */ + async annotateAuthProgress( + infos: ConnectionInfo[], + ): Promise { + for (const info of infos) { + if (info.pendingAuth !== true) continue; + const connection = this.connections.get(String(info.name)); + if (!connection) continue; + const auth = await getLiveConnectionAuthInfo(connection); + if (auth?.authorized === true) { + info.pendingAuthSignedIn = true; + info.auth = auth; + } + } + return infos; + } + getMruName(): string | null { return this.mruName; } diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 8abc44fcdd..1c211fa47b 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -253,9 +253,19 @@ export type ConnectionInfo = { * detached auth helper. The entry holds a never-connected client, so the * first op after tokens land revives (dials) it transparently. Reported by * `connect` and echoed by `connections/list`/`connections/show` until a - * revive succeeds. + * revive succeeds; `connections/show` itself revives once it sees the + * signed-in tokens on disk, so polling it observes the completion. */ pendingAuth?: boolean; + /** + * Only alongside `pendingAuth: true`: the out-of-band sign-in has already + * stored usable tokens on disk, so the connection completes on its next + * use (or on the next `connections/show`, which revives it). Set by the + * read-only echoes (`connections/list`, `connections/use`, `daemon/status`) + * from a disk check — no dial. A poller seeing this can stop waiting on the + * user and proceed to its next command. + */ + pendingAuthSignedIn?: boolean; }; /** diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index e73ab8fee4..9489d46e8b 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -406,7 +406,11 @@ export class DaemonServer { response: { id: request.id, ok: true, - result: { connections: this.registry.list() }, + result: { + connections: await this.registry.annotateAuthProgress( + this.registry.list(), + ), + }, }, }; case "connections/use": { @@ -422,22 +426,51 @@ export class DaemonServer { response: { id: request.id, ok: true, - result: this.registry.use(params.name), + result: ( + await this.registry.annotateAuthProgress([ + this.registry.use(params.name), + ]) + )[0], }, }; } case "connections/show": { const params = (request.params ?? {}) as ConnectionNameParams; - const connection = this.registry.connectionFor( + let connection = this.registry.connectionFor( params.name, params.requireExplicit, ); - const client = connection.client; // Recomputed live from disk (not the connect-time cache and not the // client's memory-cached storage): `show` reports the *current* // persisted auth state, so an auth/clear, auth/ema-logout, or a // web-client re-auth since connect is reflected here. - const auth = await getLiveConnectionAuthInfo(connection); + let auth = await getLiveConnectionAuthInfo(connection); + if (connection.pendingAuth === true && auth?.authorized === true) { + // The out-of-band sign-in completed (tokens are on disk) but no op + // has revived the entry yet. `connect` tells callers to poll here + // ("completes automatically after sign-in — check with + // `connections/show`"), so make that true: run the same revive the + // first op would, instead of reporting "pending" forever. + try { + await this.registry.liveClientFor( + params.name, + params.requireExplicit, + ); + } catch { + // Revive failed (server unreachable, tokens rejected mid-flight, + // raced disconnect). Keep show read-only-honest: fall through to + // the snapshot — the entry stays pending and a later op retries. + } + // Re-resolve: a successful revive replaced the client and cleared + // pendingAuth; a raced disconnect removed the entry (thrown here + // as the usual unknown-connection error). + connection = this.registry.connectionFor( + params.name, + params.requireExplicit, + ); + auth = await getLiveConnectionAuthInfo(connection); + } + const client = connection.client; const result: ConnectionShowResult = { name: connection.name, serverIdentity: connection.serverIdentity, @@ -462,10 +495,15 @@ export class DaemonServer { response: { id: request.id, ok: true, result }, }; } - case "daemon/status": + case "daemon/status": { + const status = this.status(); + status.connections = await this.registry.annotateAuthProgress( + status.connections, + ); return { - response: { id: request.id, ok: true, result: this.status() }, + response: { id: request.id, ok: true, result: status }, }; + } case "daemon/stop": queueMicrotask(() => { void this.stop("stop"); diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 787d3a4dc9..5d3f33e2b4 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -95,6 +95,10 @@ more). completes automatically once they sign in, which often takes only moments. Retry the intended command (sleep a few seconds between attempts) and only hand back to the user if sign-in still hasn't completed after a few tries. + To check progress without running the real command: + `connections/show @name` completes a finished sign-in itself, and + `connections/list` stays read-only but reports `pendingAuthSignedIn: true` + ("signed in — completing on next use") once the user's part is done. Never reconnect to fix a pending sign-in. ## Elicitations (server asks a question mid-call) From e3b372264db7ba4d0dcb9f98c1ccda20a35887a4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 11:50:44 -0700 Subject: [PATCH 61/69] fix(daemon-cli): override esbuild to ^0.28.2 (GHSA-g7r4-m6w7-qqqr) The daemon-cli lockfile still resolved esbuild 0.27.7 via tsup, which is affected by GHSA-g7r4-m6w7-qqqr. Add the same esbuild override the web, cli and tui packages already carry, and refresh the lockfile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/package-lock.json | 214 +++++++++++++-------------- clients/daemon-cli/package.json | 1 + 2 files changed, 108 insertions(+), 107 deletions(-) diff --git a/clients/daemon-cli/package-lock.json b/clients/daemon-cli/package-lock.json index 51ea8674dc..c1d87aceaa 100644 --- a/clients/daemon-cli/package-lock.json +++ b/clients/daemon-cli/package-lock.json @@ -15,9 +15,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -32,9 +32,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -49,9 +49,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -66,9 +66,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -83,9 +83,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -117,9 +117,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -151,9 +151,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -168,9 +168,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -185,9 +185,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -202,9 +202,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -219,9 +219,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -236,9 +236,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -270,9 +270,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -321,9 +321,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -423,9 +423,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1075,9 +1075,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1088,32 +1088,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/fdir": { diff --git a/clients/daemon-cli/package.json b/clients/daemon-cli/package.json index 64af0e72fc..477974c591 100644 --- a/clients/daemon-cli/package.json +++ b/clients/daemon-cli/package.json @@ -33,6 +33,7 @@ }, "overrides": { "@types/node": "^24.12.4", + "esbuild": "^0.28.2", "sucrase": { "commander": "^13.1.0" } From 45c45af1bd9ec063a49e70da7162d8d81b6a0e48 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 12:14:46 -0700 Subject: [PATCH 62/69] =?UTF-8?q?fix(daemon-cli):=20daemon=20robustness=20?= =?UTF-8?q?=E2=80=94=20shutdown,=20socket=20decoding,=20abort,=20lock=20ra?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from maintainer review round 2 on #1783: - disconnectAll settles each teardown independently: one failed disconnect (e.g. a connection_not_found race) no longer abandons the remaining connections and their stdio children, or makes daemon shutdown reject before the socket server closes. - Both daemon socket clients now setEncoding("utf8") so a multi-byte UTF-8 character split across TCP chunks is reassembled by the stream's StringDecoder instead of being mangled into U+FFFD per chunk. - An already-aborted signal no longer leaks a live socket: connect() was still called after onAbort() destroyed the socket, silently un-destroying it and pinning the event loop. - acquireLock treats a pidless daemon.lock younger than a 2s grace period as held (a concurrent starter between its O_EXCL create and pid write) instead of stealing it, which could let two daemons both win and permanently poison daemon.token. Symmetrically, a young pidless lock renamed aside mid-reclaim is restored, not reclaimed. Regression tests for all four (the UTF-8 test verified to fail without its fix); coverage thresholds hold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/daemon-connections.test.ts | 43 ++++++++ .../__tests__/daemon-coverage.test.ts | 100 ++++++++++++++++++ .../__tests__/daemon-stream.test.ts | 19 ++++ clients/daemon-cli/src/daemon/client.ts | 9 +- clients/daemon-cli/src/daemon/connections.ts | 13 ++- clients/daemon-cli/src/daemon/server.ts | 56 +++++++++- .../daemon-cli/src/daemon/stream-client.ts | 9 +- 7 files changed, 244 insertions(+), 5 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts index 8f8fe727ba..35857d16d5 100644 --- a/clients/daemon-cli/__tests__/daemon-connections.test.ts +++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts @@ -243,6 +243,49 @@ describe("ConnectionRegistry", () => { expect(DEFAULT_IDLE_MS).toBe(60_000); }); + it("disconnectAll tears down the remaining connections when one disconnect fails", async () => { + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + const connectSpy = vi + .spyOn(InspectorClient.prototype, "connect") + .mockResolvedValue(undefined); + const disconnectSpy = vi + .spyOn(InspectorClient.prototype, "disconnect") + .mockResolvedValue(undefined); + const authSpy = vi + .spyOn(InspectorClient.prototype, "getOAuthState") + .mockResolvedValue(undefined as never); + const registry = new ConnectionRegistry(0); + try { + const params = (name: string) => + ({ + name, + serverConfig: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + serverIdentity: "https://mcp.example.com/mcp", + }) as const; + await registry.connect(params("a")); + await registry.connect(params("b")); + // First teardown loses a race (connection_not_found); shutdown must + // still settle and tear down the rest instead of leaking "b". + const spy = vi.spyOn(registry, "disconnect"); + spy.mockRejectedValueOnce( + new CliExitCodeError(1, "No connection named 'a'.", { + code: "connection_not_found", + }), + ); + await expect(registry.disconnectAll()).resolves.toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(2); + // "b" was genuinely disconnected, not abandoned mid-loop. + expect(registry.list().map((c) => c.name)).toEqual(["a"]); + } finally { + connectSpy.mockRestore(); + disconnectSpy.mockRestore(); + authSpy.mockRestore(); + } + }); + it("serializes concurrent connects for the same name so the replaced client is torn down, not leaked", async () => { const { InspectorClient } = await import("@inspector/core/mcp/index.js"); // Slow connect widens the check→set window that raced pre-lock. diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 68d075074c..9e370ec39e 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -188,6 +188,50 @@ describe("daemon coverage", () => { expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false); }); + it("treats a young pidless lock as held instead of stealing it", async () => { + const d = freshDir(); + const lockPath = path.join(d, "daemon.lock"); + // A concurrent starter between its O_EXCL create and its pid write. + fs.writeFileSync(lockPath, ""); + const contender = new DaemonServer({ dir: d, idleMs: 0 }); + await expect(contender.start()).rejects.toThrow(/Could not acquire/); + // The other starter's lock survived untouched. + expect(fs.readFileSync(lockPath, "utf8")).toBe(""); + }); + + it("reclaims a pidless lock older than the write grace period", async () => { + const d = freshDir(); + const lockPath = path.join(d, "daemon.lock"); + // A starter that died between create and pid write, long ago. + fs.writeFileSync(lockPath, ""); + const past = (Date.now() - 60_000) / 1000; + fs.utimesSync(lockPath, past, past); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + expect(fs.readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + }); + + it("restores a young pidless lock renamed aside mid-reclaim", async () => { + const d = freshDir(); + const lockPath = path.join(d, "daemon.lock"); + fs.writeFileSync(lockPath, "999999999\n"); // dead pid triggers the reclaim + const actualFs = await vi.importActual("node:fs"); + vi.mocked(fs.renameSync).mockImplementationOnce((( + ...args: Parameters + ) => { + actualFs.renameSync(...args); + // Simulate the renamed-aside file really belonging to a concurrent + // starter that created it but has not written its pid yet: empty, + // freshly touched. + actualFs.truncateSync(args[1] as string); + }) as typeof fs.renameSync); + const contender = new DaemonServer({ dir: d, idleMs: 0 }); + await expect(contender.start()).rejects.toThrow(/Could not acquire/); + // The lock was restored at the canonical path, not stolen. + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false); + }); + it("stop() force-destroys sockets whose shutdown flush never drains", async () => { const d = freshDir(); server = new DaemonServer({ dir: d, idleMs: 0, flushTimeoutMs: 100 }); @@ -312,6 +356,62 @@ describe("daemon coverage", () => { ).rejects.toThrow(/requires a connection name/); }); + it("callDaemon reassembles a multi-byte UTF-8 character split across chunks", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const value = "héllo 👋 wörld"; + const splitter = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + const payload = Buffer.from( + JSON.stringify({ id: req.id, ok: true, result: { value } }) + "\n", + "utf8", + ); + // Split mid-emoji (0xf0 opens the 4-byte sequence) so the two TCP + // chunks each carry half of one UTF-8 character. + const mid = payload.indexOf(0xf0) + 2; + socket.write(payload.subarray(0, mid)); + setTimeout(() => socket.write(payload.subarray(mid)), 20); + }); + }); + await new Promise((resolve) => splitter.listen(sock, resolve)); + try { + const result = await callDaemon<{ value: string }>( + "ping", + {}, + { socketPath: sock, timeoutMs: 2000 }, + ); + expect(result.value).toBe(value); + } finally { + splitter.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("callDaemon with an already-aborted signal rejects without dialing", async () => { + const d = freshDir(); + const ac = new AbortController(); + ac.abort(); + // A nonexistent socket path proves no connect is attempted: dialing it + // would fail with daemon_unreachable, not cancelled. + await expect( + callDaemon( + "ping", + {}, + { + socketPath: path.join(d, "absent.sock"), + signal: ac.signal, + timeoutMs: 2000, + }, + ), + ).rejects.toMatchObject({ envelope: { code: "cancelled" } }); + }); + it("callDaemon rejects malformed response JSON", async () => { const d = freshDir(); const sock = path.join(d, "daemon.sock"); diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts index 872ed5881b..6898e7fb17 100644 --- a/clients/daemon-cli/__tests__/daemon-stream.test.ts +++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts @@ -50,6 +50,25 @@ describe("streamDaemon + ipc-glue", () => { await new Promise((resolve) => server!.listen(sock, resolve)); } + it("resolves immediately on an already-aborted signal without dialing", async () => { + const sock = freshSock(); + const ac = new AbortController(); + ac.abort(); + // No server listens at `sock`: a dial would reject with + // daemon_unreachable, so resolving proves connect() was never called. + await expect( + streamDaemon( + { method: "logging/tail" }, + { + socketPath: sock, + timeoutMs: 2000, + signal: ac.signal, + onData: () => {}, + }, + ), + ).resolves.toBeUndefined(); + }); + it("delivers data frames then end (skips blank/mismatched ids)", async () => { const sock = freshSock(); await listen(sock, (socket) => { diff --git a/clients/daemon-cli/src/daemon/client.ts b/clients/daemon-cli/src/daemon/client.ts index c0e126e28d..6a6fa5a9b6 100644 --- a/clients/daemon-cli/src/daemon/client.ts +++ b/clients/daemon-cli/src/daemon/client.ts @@ -107,6 +107,10 @@ export async function callDaemon( // synchronously (prefer-const would put `timer` in the TDZ for that race). let timer: ReturnType | undefined; const socket = new net.Socket(); + // Decode at the socket: a multi-byte UTF-8 character split across TCP + // chunks must be reassembled by the stream's StringDecoder, not mangled + // into U+FFFD by a per-chunk String() conversion. + socket.setEncoding("utf8"); function settle(fn: () => void) { /* v8 ignore next -- settle() no-op when already settled (connect/timeout race) */ @@ -270,6 +274,9 @@ export async function callDaemon( } }); - socket.connect(socketPath); + // A pre-aborted signal settles above via onAbort() and destroys the + // socket; connect() would silently un-destroy it and leak a live socket + // that pins the event loop. + if (!settled) socket.connect(socketPath); }); } diff --git a/clients/daemon-cli/src/daemon/connections.ts b/clients/daemon-cli/src/daemon/connections.ts index e8b454c94b..36deebabbc 100644 --- a/clients/daemon-cli/src/daemon/connections.ts +++ b/clients/daemon-cli/src/daemon/connections.ts @@ -543,7 +543,18 @@ export class ConnectionRegistry { this.closed = true; const names = [...this.connections.keys()]; for (const name of names) { - await this.disconnect(name, false); + // Settle each teardown independently: one failed disconnect (e.g. a + // connection_not_found race with a concurrent explicit disconnect) + // must not abandon the remaining connections — that would leak live + // clients and stdio child processes, and make daemon shutdown reject + // before the socket server closes. + try { + await this.disconnect(name, false); + } catch { + // Best-effort teardown on shutdown; the connection is already gone + // or its transport close failed, neither of which should block the + // rest. + } } this.clearIdleTimer(); } diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 9489d46e8b..5cc6937b79 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -830,6 +830,10 @@ export class DaemonServer { * read-pid → unlink window of another. If the renamed-aside file turns out * to hold a *live* pid (created between our read and the rename), it is * restored with a create-only `link` — ownership-preserving, same inode. + * A lock with *no* pid is only stale once it outlives + * {@link LOCK_WRITE_GRACE_MS}: younger than that, it belongs to a starter + * that has created the file but not yet written its pid, so it is treated + * as held (and restored if already renamed aside) rather than stolen. */ private acquireLock(): void { for (let attempt = 0; attempt < 3; attempt++) { @@ -848,6 +852,18 @@ export class DaemonServer { { cause: error }, ); } + if ( + holder === undefined && + lockFileAgeMs(this.lockPath) < LOCK_WRITE_GRACE_MS + ) { + // Empty (or unparsable) but young: a concurrent starter is between + // its O_EXCL create and its pid write. Stealing it here would let + // both daemons win — treat it as held and retry after a beat. Only + // a lock still empty past the grace period (a starter that died + // mid-create) is stale. + sleepSync(LOCK_RETRY_DELAY_MS); + continue; + } const claimed = `${this.lockPath}.reclaim.${process.pid}`; try { fs.renameSync(this.lockPath, claimed); @@ -856,9 +872,16 @@ export class DaemonServer { continue; } const claimedPid = this.readPidFile(claimed); - if (claimedPid !== undefined && isPidAlive(claimedPid)) { + if ( + (claimedPid !== undefined && isPidAlive(claimedPid)) || + (claimedPid === undefined && + lockFileAgeMs(claimed) < LOCK_WRITE_GRACE_MS) + ) { // We renamed away a lock that a concurrent starter created between - // our dead-pid read and the rename. Put it back without breaking + // our dead-pid read and the rename — either it already holds a + // live pid, or it is still empty inside the pid-write grace + // period (the starter's fd targets this same inode, so its write + // still lands after the restore). Put it back without breaking // that starter's ownership: link() re-creates the path for the // same inode and fails (EEXIST) rather than overwriting. try { @@ -872,6 +895,10 @@ export class DaemonServer { } catch { // best-effort temp cleanup } + if (claimedPid === undefined) { + sleepSync(LOCK_RETRY_DELAY_MS); + continue; + } throw new Error( `Connection daemon lock ${this.lockPath} is held by running pid ${claimedPid}. ` + `Use \`mcpdo daemon/stop\`, or remove the file if that pid is not an mcpdo daemon.`, @@ -939,6 +966,31 @@ function isPidAlive(pid: number): boolean { } } +/** + * How long a pidless `daemon.lock` is presumed to belong to a concurrent + * starter that is between its O_EXCL create and its pid write, rather than + * to a starter that died mid-create. Generous against a stalled writer while + * still reclaiming a genuinely abandoned empty lock promptly. + */ +const LOCK_WRITE_GRACE_MS = 2000; + +/** Backoff between lock-acquisition retries while inside the grace period. */ +const LOCK_RETRY_DELAY_MS = 100; + +/** Age of `filePath` since last write; missing/unstattable counts as stale. */ +function lockFileAgeMs(filePath: string): number { + try { + return Date.now() - fs.statSync(filePath).mtimeMs; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +/** Synchronous sleep — acquireLock() runs in the sync startup path. */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function stripConnectionFields( params: RpcParams, ): MethodArgs & { method: string } { diff --git a/clients/daemon-cli/src/daemon/stream-client.ts b/clients/daemon-cli/src/daemon/stream-client.ts index f692003c89..a4da01d458 100644 --- a/clients/daemon-cli/src/daemon/stream-client.ts +++ b/clients/daemon-cli/src/daemon/stream-client.ts @@ -52,6 +52,10 @@ export async function streamDaemon( let pendingCallbacks = 0; let timer: ReturnType | undefined; const socket = new net.Socket(); + // Decode at the socket: a multi-byte UTF-8 character split across TCP + // chunks must be reassembled by the stream's StringDecoder, not mangled + // into U+FFFD by a per-chunk String() conversion. + socket.setEncoding("utf8"); function settle(fn: () => void) { if (settled) return; @@ -232,6 +236,9 @@ export async function streamDaemon( } }); - socket.connect(socketPath); + // A pre-aborted signal settles above via onAbort() and destroys the + // socket; connect() would silently un-destroy it and leak a live socket + // that pins the event loop. + if (!settled) socket.connect(socketPath); }); } From a606e39da4fb0362eb5908cecfe3437a95fa9fcb Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 12:38:43 -0700 Subject: [PATCH 63/69] fix(daemon-cli): keep piped stdin answers across elicitation questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elicitation prompts previously created a fresh readline interface per exchange, so answers piped up front (e.g. printf "a\nb\n" | mcpdo ...) were buffered into the first interface and discarded when it closed — only the first answer survived. Replace per-exchange readline usage with a shared persistent PromptReader that queues incoming lines and hands them to questions as they are asked, across questions and elicitation rounds. 'Input closed' now means truly exhausted (EOF and empty queue), so a completable form is never cancelled while queued answers remain. Verified against the reviewer's repro: pre-fix the second piped answer was dropped and the form cancelled; post-fix all answers are consumed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/elicitation-prompt.test.ts | 11 +- .../__tests__/prompt-reader.test.ts | 101 ++++++++++++ .../src/connection/elicitation-prompt.ts | 19 +-- .../daemon-cli/src/connection/form-prompt.ts | 23 +-- .../src/connection/prompt-reader.ts | 156 ++++++++++++++++++ 5 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 clients/daemon-cli/__tests__/prompt-reader.test.ts create mode 100644 clients/daemon-cli/src/connection/prompt-reader.ts diff --git a/clients/daemon-cli/__tests__/elicitation-prompt.test.ts b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts index 5d4b420c41..bfb4d17ccc 100644 --- a/clients/daemon-cli/__tests__/elicitation-prompt.test.ts +++ b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts @@ -3,13 +3,12 @@ import { createStyle } from "@inspector/cli/style.js"; import type { ElicitationRequestFrame } from "../src/daemon/protocol.js"; const question = vi.fn(); -const close = vi.fn(); const promptFormMock = vi.fn(); const once = vi.fn(); -vi.mock("node:readline/promises", () => ({ - createInterface: () => ({ question, close, once }), +vi.mock("../src/connection/prompt-reader.js", () => ({ + getSharedPromptReader: () => ({ question, once }), })); vi.mock("../src/connection/form-prompt.js", async () => { @@ -44,7 +43,6 @@ describe("promptElicitation", () => { return true; }) as typeof process.stderr.write; question.mockReset(); - close.mockReset(); once.mockReset(); promptFormMock.mockReset(); }); @@ -171,7 +169,6 @@ describe("promptElicitation", () => { elicitationId: "elicitation-1", action: "accept", }); - expect(close).toHaveBeenCalled(); expect(stderr).toContain("Please confirm"); expect(stderr).toContain("https://example.com/confirm"); }); @@ -213,7 +210,6 @@ describe("promptElicitation", () => { const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); - expect(close).toHaveBeenCalled(); }); it("cancels URL mode if stdin closes before the user answers", async () => { @@ -229,7 +225,6 @@ describe("promptElicitation", () => { const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); - expect(close).toHaveBeenCalled(); }); it("accepts an interactive form submission and returns its content", async () => { @@ -248,7 +243,6 @@ describe("promptElicitation", () => { action: "accept", content: { name: "octocat" }, }); - expect(close).toHaveBeenCalled(); }); it("declines an interactive form when promptForm reports decline", async () => { @@ -276,6 +270,5 @@ describe("promptElicitation", () => { const frame = formFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); - expect(close).toHaveBeenCalled(); }); }); diff --git a/clients/daemon-cli/__tests__/prompt-reader.test.ts b/clients/daemon-cli/__tests__/prompt-reader.test.ts new file mode 100644 index 0000000000..2b3752e7fa --- /dev/null +++ b/clients/daemon-cli/__tests__/prompt-reader.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { PassThrough } from "node:stream"; +import { + PromptReader, + getSharedPromptReader, + resetSharedPromptReader, +} from "../src/connection/prompt-reader.js"; + +/** + * Covers the persistent line-queue reader that backs interactive prompts: + * piped input buffered ahead of the questions (the `printf 'a\nb\n' | + * mcpdo tools/call …` case) must answer every question, and "close" must + * mean input *exhausted* (EOF and empty queue), not merely EOF. + */ +describe("PromptReader", () => { + let reader: PromptReader | undefined; + + afterEach(() => { + reader?.dispose(); + reader = undefined; + resetSharedPromptReader(); + }); + + function make(): { + reader: PromptReader; + input: PassThrough; + output: () => string; + } { + const input = new PassThrough(); + const out = new PassThrough(); + let written = ""; + out.on("data", (chunk) => { + written += String(chunk); + }); + reader = new PromptReader( + input as unknown as NodeJS.ReadStream, + out as unknown as NodeJS.WriteStream, + ); + return { reader, input, output: () => written }; + } + + it("answers sequential questions from one up-front piped burst", async () => { + const { reader, input, output } = make(); + // All three answers arrive before any question is asked — the exact + // shape of `printf 'alice\n30\n\n' | mcpdo tools/call register`. + input.write("alice\n30\n\n"); + await expect(reader.question("Name: ")).resolves.toBe("alice"); + await expect(reader.question("Age: ")).resolves.toBe("30"); + await expect(reader.question("Color: ")).resolves.toBe(""); + expect(output()).toBe("Name: Age: Color: "); + }); + + it("resolves a pending question when its line arrives later", async () => { + const { reader, input } = make(); + const pending = reader.question("Name: "); + input.write("octocat\n"); + await expect(pending).resolves.toBe("octocat"); + }); + + it("still answers from the queue after EOF, then reports exhaustion", async () => { + const { reader, input } = make(); + let closed = 0; + reader.once("close", () => { + closed += 1; + }); + input.end("alice\n"); + // Give readline a beat to flush the final chunk and see EOF. + await expect(reader.question("Name: ")).resolves.toBe("alice"); + // EOF alone must not have fired "close" while an answer was queued. + expect(closed).toBe(0); + await expect(reader.question("Age: ")).rejects.toThrow(/stdin closed/); + expect(closed).toBe(1); + // A listener registered after exhaustion fires immediately. + reader.once("close", () => { + closed += 1; + }); + expect(closed).toBe(2); + // And later questions keep rejecting without hanging. + await expect(reader.question("More: ")).rejects.toThrow(/stdin closed/); + }); + + it("rejects a question pending at EOF and notifies close watchers", async () => { + const { reader, input } = make(); + let closed = false; + reader.once("close", () => { + closed = true; + }); + const pending = reader.question("Name: "); + input.end(); + await expect(pending).rejects.toThrow(/stdin closed/); + expect(closed).toBe(true); + }); + + it("shares one process-wide reader, and reset disposes it", () => { + const first = getSharedPromptReader(); + expect(getSharedPromptReader()).toBe(first); + resetSharedPromptReader(); + const second = getSharedPromptReader(); + expect(second).not.toBe(first); + }); +}); diff --git a/clients/daemon-cli/src/connection/elicitation-prompt.ts b/clients/daemon-cli/src/connection/elicitation-prompt.ts index 766f9ffb9d..158220060a 100644 --- a/clients/daemon-cli/src/connection/elicitation-prompt.ts +++ b/clients/daemon-cli/src/connection/elicitation-prompt.ts @@ -13,7 +13,6 @@ * Schemas outside the spec's restricted primitive-field shape (should * never happen from a well-behaved server) fall back to a clear decline. */ -import { createInterface } from "node:readline/promises"; import type { Style } from "@inspector/cli/style.js"; import type { ElicitationRequestFrame, @@ -21,6 +20,7 @@ import type { } from "../daemon/protocol.js"; import { parseFormSchema } from "./form-schema.js"; import { promptForm, watchForClose } from "./form-prompt.js"; +import { getSharedPromptReader } from "./prompt-reader.js"; import { isSafeLinkTarget, sanitizeText } from "./sanitize.js"; export type PromptElicitationOpts = { @@ -105,10 +105,10 @@ export async function promptElicitation( return declineResponse(frame); } - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - }); + // The shared reader outlives this exchange on purpose: piped answers + // for later fields/rounds arrive before their questions are asked, and + // a per-exchange interface would drop them (see prompt-reader.ts). + const rl = getSharedPromptReader(); try { const outcome = await promptForm(rl, message, fields, style); if (outcome.action === "accept") { @@ -124,8 +124,6 @@ export async function promptElicitation( return cancelResponse(frame); } catch { return cancelResponse(frame); - } finally { - rl.close(); } } @@ -155,10 +153,7 @@ export async function promptElicitation( "\n\n", ); - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - }); + const rl = getSharedPromptReader(); try { const answer = await Promise.race([ rl.question( @@ -178,7 +173,5 @@ export async function promptElicitation( }; } catch { return cancelResponse(frame); - } finally { - rl.close(); } } diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts index 0a469da5a1..dc27365fec 100644 --- a/clients/daemon-cli/src/connection/form-prompt.ts +++ b/clients/daemon-cli/src/connection/form-prompt.ts @@ -6,8 +6,8 @@ * range), then shows a review step before submitting so the user can * re-edit any field or cancel outright. */ -import type { Interface as ReadlineInterface } from "node:readline/promises"; import type { Style } from "@inspector/cli/style.js"; +import type { PromptInput } from "./prompt-reader.js"; import type { FormField } from "./form-schema.js"; import { codePointLength } from "./form-schema.js"; import { sanitizeText } from "./sanitize.js"; @@ -18,14 +18,15 @@ export type FormOutcome = | { action: "cancel" }; /** - * A promise that rejects the first time `rl`'s underlying input stream - * closes (EOF on a redirected/piped stdin, or the readline interface being - * closed elsewhere). Racing every `rl.question()` against this means a - * closed-before-answered stdin (e.g. `mcpdo ... { +export function watchForClose(rl: PromptInput): Promise { return new Promise((_, reject) => { rl.once("close", () => reject(new Error("stdin closed before an answer was given")), @@ -35,7 +36,7 @@ export function watchForClose(rl: ReadlineInterface): Promise { /** `rl.question()`, but rejects instead of hanging if stdin closes first. */ function ask( - rl: ReadlineInterface, + rl: PromptInput, closed: Promise, prompt: string, ): Promise { @@ -63,7 +64,7 @@ function describeField(field: FormField, style: Style): string { /** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */ async function promptField( - rl: ReadlineInterface, + rl: PromptInput, closed: Promise, field: FormField, style: Style, @@ -231,7 +232,7 @@ async function promptField( * field by name / cancel) until the user submits or cancels. */ export async function promptForm( - rl: ReadlineInterface, + rl: PromptInput, message: string, fields: FormField[], style: Style, diff --git a/clients/daemon-cli/src/connection/prompt-reader.ts b/clients/daemon-cli/src/connection/prompt-reader.ts new file mode 100644 index 0000000000..8f65fe4b56 --- /dev/null +++ b/clients/daemon-cli/src/connection/prompt-reader.ts @@ -0,0 +1,156 @@ +/** + * Process-wide line reader for interactive prompts (elicitation forms and + * URL confirmations). + * + * Why not one `readline` interface per prompt exchange: readline discards + * `line` events that fire while no `question()` is pending, and closing an + * interface discards whatever input it already buffered. With a piped stdin + * (`printf 'alice\n30\n' | mcpdo tools/call register`) every buffered line + * after the first arrives while no question is listening — and the next + * exchange's fresh interface starts from an empty (often already-EOF) + * stream. So answers beyond the first were silently dropped. + * + * This reader owns a single persistent interface: every `line` event lands + * in a queue, `question()` consumes from the queue before waiting for new + * input, and "close" means *input exhausted* — EOF **and** an empty queue — + * not merely EOF, so piped answers already received still get delivered. + * Between questions the underlying stream is paused and unref'd, so the + * reader never pins the event loop or holds a TTY hostage. + */ +import * as readline from "node:readline"; + +/** + * The structural surface prompts consume: sequential questions plus an + * exhausted-input notification. Implemented by {@link PromptReader}; + * narrow enough for tests to fake. + */ +export type PromptInput = { + question(prompt: string): Promise; + once(event: "close", listener: () => void): unknown; +}; + +type Waiter = { + resolve: (line: string) => void; + reject: (error: Error) => void; +}; + +function exhaustedError(): Error { + return new Error("stdin closed before an answer was given"); +} + +export class PromptReader implements PromptInput { + private readonly rl: readline.Interface; + private readonly input: NodeJS.ReadStream; + private readonly output: NodeJS.WriteStream; + private readonly queued: string[] = []; + private waiter: Waiter | undefined; + private closeListeners: Array<() => void> = []; + private eof = false; + private exhaustedNotified = false; + + constructor( + input: NodeJS.ReadStream = process.stdin, + output: NodeJS.WriteStream = process.stderr, + ) { + this.input = input; + this.output = output; + this.rl = readline.createInterface({ input, output }); + this.rl.on("line", (line) => { + const waiter = this.waiter; + if (waiter) { + this.waiter = undefined; + this.park(); + waiter.resolve(line); + } else { + // No question pending (between fields, or input arrived up front): + // keep the line for the next question instead of dropping it. + this.queued.push(line); + } + }); + this.rl.once("close", () => { + this.eof = true; + const waiter = this.waiter; + if (waiter) { + this.waiter = undefined; + this.notifyExhausted(); + waiter.reject(exhaustedError()); + } + }); + this.park(); + } + + /** + * Write `prompt` and resolve with the next input line — a queued one + * first, else the next to arrive. Rejects once input is exhausted (EOF + * with nothing queued). + */ + question(prompt: string): Promise { + this.output.write(prompt); + const queued = this.queued.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (this.eof) { + this.notifyExhausted(); + return Promise.reject(exhaustedError()); + } + this.engage(); + return new Promise((resolve, reject) => { + this.waiter = { resolve, reject }; + }); + } + + /** + * `close` here means input is exhausted: EOF *and* no queued line left to + * answer with. A raw stream close while answers are still queued must not + * cancel a form those answers can complete. + */ + once(event: "close", listener: () => void): this { + if (event === "close") { + if (this.exhaustedNotified) listener(); + else this.closeListeners.push(listener); + } + return this; + } + + /** Tear down the underlying interface (tests / process cleanup). */ + dispose(): void { + this.rl.close(); + } + + private notifyExhausted(): void { + if (this.exhaustedNotified) return; + this.exhaustedNotified = true; + const listeners = this.closeListeners; + this.closeListeners = []; + for (const listener of listeners) listener(); + } + + /** Actively waiting for a line: let the stream flow and hold the loop. */ + private engage(): void { + this.input.ref?.(); + this.rl.resume(); + } + + /** Idle between questions: stop reading and release the event loop. */ + private park(): void { + this.rl.pause(); + this.input.unref?.(); + } +} + +let shared: PromptReader | undefined; + +/** + * The stdin/stderr reader shared by every prompt in this process. Lazy: a + * run that never prompts never touches stdin. Persistent: consecutive + * elicitation exchanges in one command must share buffered piped input. + */ +export function getSharedPromptReader(): PromptReader { + shared ??= new PromptReader(); + return shared; +} + +/** Test hook: drop (and dispose) the shared reader between cases. */ +export function resetSharedPromptReader(): void { + shared?.dispose(); + shared = undefined; +} From 2cabc86c73569b01357e3d6701cdfc69bac444a7 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 14:16:11 -0700 Subject: [PATCH 64/69] fix(daemon-cli): review round-2 follow-ups in terminal output paths - logging/tail: object `data` in a log notification now renders as JSON instead of "[object Object]" (format-human.ts). - Sign-in helper: listen for "close" instead of "exit" so a final buffered stdout line (e.g. {"event":"error"}) is parsed before the failure path runs (auth-helper.ts). - Failure paths now sanitize server-influenced text the same way the success path does: helper error messages and tool names interpolated into error envelopes get C0/C1 controls replaced with visible stand-ins (auth-helper.ts, format-connection.ts). - Document why mcp-bin.ts and daemon/run.ts are excluded from coverage (vitest.config.ts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/format-connection.test.ts | 25 +++++++++++++++++++ .../daemon-cli/src/connection/auth-helper.ts | 10 ++++++-- .../src/connection/format-connection.ts | 6 +++-- .../daemon-cli/src/connection/format-human.ts | 7 +++--- clients/daemon-cli/vitest.config.ts | 3 +++ 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts index 59dddccc52..d32bd8714c 100644 --- a/clients/daemon-cli/__tests__/format-connection.test.ts +++ b/clients/daemon-cli/__tests__/format-connection.test.ts @@ -522,6 +522,16 @@ describe("format-human", () => { }, }), ).toBe("[warn] L: hi"); + // Object `data` renders as JSON, not "[object Object]". + expect( + formatStreamEventHuman({ + direction: "notification", + message: { + method: "notifications/message", + params: { level: "info", data: { job: "sync", ok: true } }, + }, + }), + ).toBe('[info] {"job":"sync","ok":true}'); expect( formatStreamEventHuman({ direction: "notification", @@ -963,6 +973,21 @@ describe("writeConnectionOutput", () => { }, ), ).rejects.toMatchObject({ message: expect.stringContaining("tool") }); + // Server-influenced tool names are sanitized before reaching the + // terminal-bound error message (C0/C1 → visible stand-ins). + await expect( + writeConnectionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { isError: true, content: [] }, + toolName: "evil\u001b]0;pwned\u0007", + }, + ), + ).rejects.toMatchObject({ + message: expect.not.stringContaining("\u001b"), + }); }); it("falls back to pretty JSON for unknown rpc methods in text mode", async () => { diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts index e9132ea628..2a5548c389 100644 --- a/clients/daemon-cli/src/connection/auth-helper.ts +++ b/clients/daemon-cli/src/connection/auth-helper.ts @@ -10,6 +10,7 @@ import type { import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { getDaemonDir } from "../daemon/paths.js"; import { authorizeInFrontend } from "./authorize.js"; +import { sanitizeText } from "./sanitize.js"; /** * Detached OAuth completion helper for the non-TTY `connect` path. @@ -411,12 +412,17 @@ async function spawnAuthHelperForUrl( } if (event.event === "error") { clearTimeout(timer); - fail(`Sign-in helper failed: ${event.message}`); + // The helper relays server-derived text; strip C0/C1 controls + // before it reaches a terminal via the error envelope. + fail(`Sign-in helper failed: ${sanitizeText(event.message)}`); return; } } }); - child.on("exit", (code) => { + // "close", not "exit": exit can fire while the final stdout line + // (e.g. `{"event":"error",...}`) is still buffered; close waits for + // the stdio streams to drain so that line is parsed first. + child.on("close", (code) => { clearTimeout(timer); fail( `Sign-in helper exited (code ${String(code)}) before producing an authorization URL.`, diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 5a4b226cdf..3f8539ddc8 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -374,7 +374,9 @@ function applyExitCodes(payload: ConnectionWriteKind): void { if (!info.hasApp) { throw new CliExitCodeError( EXIT_CODES.NO_APP, - `Tool '${info.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + // toolName echoes server-influenced text into a terminal-bound + // error message; sanitize like the success path does. + `Tool '${sanitizeText(info.toolName)}' has no MCP App UI resource (_meta.ui.resourceUri).`, ); } return; @@ -382,7 +384,7 @@ function applyExitCodes(payload: ConnectionWriteKind): void { if (payload.result.isError === true) { throw new CliExitCodeError( EXIT_CODES.TOOL_ERROR, - `Tool '${payload.toolName ?? "tool"}' returned isError:true.`, + `Tool '${sanitizeText(payload.toolName ?? "tool")}' returned isError:true.`, { code: "tool_is_error" }, ); } diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts index 03d5bba783..6e167baad8 100644 --- a/clients/daemon-cli/src/connection/format-human.ts +++ b/clients/daemon-cli/src/connection/format-human.ts @@ -892,9 +892,10 @@ export function formatStreamEventHuman( const params = (msg.params ?? {}) as JsonObject; const level = String(params.level ?? "info"); const logger = params.logger ? style.dim(` ${String(params.logger)}:`) : ""; - const text = String( - params.data ?? params.message ?? JSON.stringify(params), - ); + // Servers may log structured `data`; String() would render it as + // "[object Object]", so non-strings get JSON instead. + const raw = params.data ?? params.message ?? params; + const text = typeof raw === "string" ? raw : JSON.stringify(raw); return `[${colorLevel(style, level)}]${logger} ${text}`; } return JSON.stringify(ev, null, 2); diff --git a/clients/daemon-cli/vitest.config.ts b/clients/daemon-cli/vitest.config.ts index 5cb512d3f5..6d6486495e 100644 --- a/clients/daemon-cli/vitest.config.ts +++ b/clients/daemon-cli/vitest.config.ts @@ -37,6 +37,9 @@ export default defineConfig({ provider: "v8", reporter: ["text", "html", "json-summary"], include: ["src/**/*.ts"], + // Process entry points only: argv/env wiring plus a top-level call into + // covered modules. Exercised by spawning real processes (daemon spawn in + // tests, smoke), which v8 coverage can't observe from the parent. exclude: ["src/mcp-bin.ts", "src/daemon/run.ts"], thresholds: { perFile: true, From 8250d0bd36d9b6881576dca88660be5e15ef0396 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 14:22:34 -0700 Subject: [PATCH 65/69] fix(scripts, docs): eval-harness correctness and shipped-skill guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eval matcher: parse the `--flag=value` spelling; `--connection=x` no longer reads as an unknown boolean flag that drops the value (false hits/misses in eval scores). - Eval shim: exit via process.exitCode instead of process.exit(), which discarded queued stdout writes and truncated large JSON results. - skills:eval env allowlist: pass AWS_*/GOOGLE_*/CLOUD_ML_* through for claude — Bedrock/Vertex runs need them to authenticate. - verify:skills: also validate shipped skills under `skills/` (frontmatter/structure only; no eval-case or listing-budget rules) so a truncated skills/mcpdo/SKILL.md cannot ship silently. - skills/mcpdo/SKILL.md: clarify that a parked elicitation means the command has already exited (exit 0) while the MCP tool call waits daemon-side — agents must not wait on or time-box the command. - daemon-cli README: note private mode separates daemons from each other, not from same-UID processes that learn the daemon dir. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/README.md | 6 ++++++ scripts/lib/mcpdo-eval-matchers.mjs | 17 ++++++++++++++--- scripts/lib/mcpdo-eval-matchers.test.mjs | 7 +++++++ scripts/lib/mcpdo-eval-shim.mjs | 12 ++++++++++-- scripts/skill-eval.mjs | 5 ++++- scripts/skill-eval.test.mjs | 11 +++++++++-- scripts/verify-skills.mjs | 20 ++++++++++++++++++++ skills/mcpdo/SKILL.md | 6 ++++-- 8 files changed, 74 insertions(+), 10 deletions(-) diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md index 042c2daaa2..7bcba5a717 100644 --- a/clients/daemon-cli/README.md +++ b/clients/daemon-cli/README.md @@ -85,6 +85,12 @@ mcpdo connect test-stdio --config path/to/mcp.json mcpdo tools/list ``` +**Private mode:** `eval "$(mcpdo private)"` gives the shell its own daemon and +bearer token, separating its connections and daemon state from other mcpdo +daemons. It is not a security boundary against other processes running as your +user: anything with the same UID that learns the daemon directory can read the +token. For a hard boundary, use OS-level isolation (separate user, container). + **Globals (before subcommand):** `--format text|json`, `--plain`, `--connection ` (shorthand: `--conn`), `--catalog` / `--config`, `--stored-auth-only`. **Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope. diff --git a/scripts/lib/mcpdo-eval-matchers.mjs b/scripts/lib/mcpdo-eval-matchers.mjs index faa58331aa..236762f7a9 100644 --- a/scripts/lib/mcpdo-eval-matchers.mjs +++ b/scripts/lib/mcpdo-eval-matchers.mjs @@ -71,9 +71,20 @@ export function parseMcpdoArgv(argv) { const positionals = []; for (let i = 0; i < argv.length; i++) { - const token = argv[i]; + let token = argv[i]; + // `--flag=value` form: split so the flag matches the sets below the same + // as the space-separated form; otherwise `--connection=x` would parse as + // an unknown boolean flag and silently drop the value. + let inline = null; + if (token.startsWith("--")) { + const eq = token.indexOf("="); + if (eq !== -1) { + inline = token.slice(eq + 1); + token = token.slice(0, eq); + } + } if (VARIADIC_FLAGS.has(token)) { - const pairs = []; + const pairs = inline !== null ? [inline] : []; while ( i + 1 < argv.length && !argv[i + 1].startsWith("-") && @@ -90,7 +101,7 @@ export function parseMcpdoArgv(argv) { continue; } if (VALUE_FLAGS.has(token)) { - const value = argv[++i]; + const value = inline ?? argv[++i]; if (token === "--connection" || token === "--conn") connection = value; if (token === "--tool-name") toolNameFlag = value; if (token === "--tool-args-json") { diff --git a/scripts/lib/mcpdo-eval-matchers.test.mjs b/scripts/lib/mcpdo-eval-matchers.test.mjs index b985feed5b..85ced386b3 100644 --- a/scripts/lib/mcpdo-eval-matchers.test.mjs +++ b/scripts/lib/mcpdo-eval-matchers.test.mjs @@ -51,6 +51,13 @@ test("parseMcpdoArgv: every tools/call spelling normalizes the same", () => { "--connection", "test-stdio", ], + [ + "tools/call", + "--tool-name=get_sum", + "--tool-arg=a=2", + "b=3", + "--connection=test-stdio", + ], ]; for (const argv of spellings) { const p = parseMcpdoArgv(argv); diff --git a/scripts/lib/mcpdo-eval-shim.mjs b/scripts/lib/mcpdo-eval-shim.mjs index ac204de311..3143c1a2d1 100644 --- a/scripts/lib/mcpdo-eval-shim.mjs +++ b/scripts/lib/mcpdo-eval-shim.mjs @@ -122,10 +122,18 @@ if (isMain) { stdout: process.stdout, stderr: process.stderr, }).then( - (code) => process.exit(code), + // `process.exitCode` + natural exit, not `process.exit()`: exit() + // discards queued stdout/stderr writes, truncating large JSON results + // on macOS pipes. Destroying stdin releases the last open handle so + // the process drains its writes and exits on its own. + (code) => { + process.exitCode = code; + process.stdin.destroy(); + }, (err) => { process.stderr.write(`mcpdo-eval-shim: ${err?.message ?? err}\n`); - process.exit(2); + process.exitCode = 2; + process.stdin.destroy(); }, ); } diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 22aa20a968..2e78f33167 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -712,7 +712,10 @@ export function agentEnv(agent, source = process.env) { const prefixes = agent === "copilot" ? ["GITHUB_", "GH_", "COPILOT_", "XDG_"] - : ["ANTHROPIC_", "CLAUDE_", "XDG_"]; + : // AWS_/GOOGLE_/CLOUD_ML_ carry Bedrock and Vertex credentials/region; + // claude routes through them when CLAUDE_CODE_USE_BEDROCK/VERTEX is + // set, and dropping them fails auth on those runs. + ["ANTHROPIC_", "CLAUDE_", "XDG_", "AWS_", "GOOGLE_", "CLOUD_ML_"]; const env = {}; for (const key of Object.keys(source)) { if (source[key] === undefined) continue; diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index cfc245f8f9..76ad47ac6d 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -1059,7 +1059,9 @@ test("agentEnv: agents get only process basics and their own credentials", () => const source = { PATH: "/usr/bin", HOME: "/Users/dev", - AWS_SECRET_ACCESS_KEY: "leak-me-not", + AWS_SECRET_ACCESS_KEY: "bedrock-key", + GOOGLE_APPLICATION_CREDENTIALS: "/creds.json", + CLOUD_ML_REGION: "us-east5", NPM_TOKEN: "leak-me-not", ANTHROPIC_API_KEY: "claude-key", CLAUDE_CODE_FLAG: "1", @@ -1074,7 +1076,11 @@ test("agentEnv: agents get only process basics and their own credentials", () => assert.equal(claude.ANTHROPIC_API_KEY, "claude-key"); assert.equal(claude.CLAUDE_CODE_FLAG, "1"); assert.equal(claude.XDG_CONFIG_HOME, "/Users/dev/.config"); - assert.ok(!("AWS_SECRET_ACCESS_KEY" in claude)); + // Bedrock/Vertex credentials are claude's own auth route + // (CLAUDE_CODE_USE_BEDROCK/VERTEX), so AWS_/GOOGLE_/CLOUD_ML_ pass through. + assert.equal(claude.AWS_SECRET_ACCESS_KEY, "bedrock-key"); + assert.equal(claude.GOOGLE_APPLICATION_CREDENTIALS, "/creds.json"); + assert.equal(claude.CLOUD_ML_REGION, "us-east5"); assert.ok(!("NPM_TOKEN" in claude)); assert.ok(!("GH_TOKEN" in claude)); assert.ok(!("GITHUB_TOKEN" in claude)); @@ -1084,6 +1090,7 @@ test("agentEnv: agents get only process basics and their own credentials", () => assert.equal(copilot.COPILOT_MODEL, "m"); assert.ok(!("ANTHROPIC_API_KEY" in copilot)); assert.ok(!("AWS_SECRET_ACCESS_KEY" in copilot)); + assert.ok(!("GOOGLE_APPLICATION_CREDENTIALS" in copilot)); }); test("runPrompt: spawned agent env is minimal plus the caller's overlay", async () => { diff --git a/scripts/verify-skills.mjs b/scripts/verify-skills.mjs index 891c6eda4a..5d4addfbd3 100755 --- a/scripts/verify-skills.mjs +++ b/scripts/verify-skills.mjs @@ -312,6 +312,26 @@ function main(argv = process.argv.slice(2)) { ); } + // Shipped skills (`skills/`, packaged with a client rather than loaded from + // `.claude/skills`) get the same frontmatter/structure validation — a + // truncated SKILL.md would otherwise ship silently — but no eval-case or + // listing-budget requirements: they are not part of this repo's own + // agent skill listing. + if (!override && existsSync(path.join(ROOT, "skills"))) { + const shippedDir = path.join(ROOT, "skills"); + for (const dir of skillDirs(shippedDir)) { + const file = path.join(shippedDir, dir, "SKILL.md"); + if (!existsSync(file)) { + failures.push(`skills/${dir}: no SKILL.md`); + continue; + } + const skill = parseSkill(dir, readFileSync(file, "utf8")); + for (const e of skill.errors) { + failures.push(`skills/${dir}/SKILL.md: ${e}`); + } + } + } + if (failures.length > 0) { console.error(`verify:skills — ${failures.length} problem(s):\n`); for (const f of failures) console.error(" " + f); diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 5d3f33e2b4..720b3f6ab3 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -104,9 +104,11 @@ more). ## Elicitations (server asks a question mid-call) - On an interactive TTY, mcpdo prompts inline. From an agent shell (non-TTY or - `--format json`), the call instead **parks** and exits 0 with an + `--format json`), the **command returns immediately** (exit 0) with an `elicitationPending` payload carrying the question, schema, and an - `elicitationId`. + `elicitationId`; the underlying MCP **tool call stays parked** on the daemon + awaiting your response. Never wait on or time-box the mcpdo command itself — + it has already exited; the pending work lives daemon-side. - Answer with `mcpdo elicitation/respond field:=value ...` (repeat if the server asks again), or end it with `--decline` or `--cancel`. For URL-mode elicitations, relay the URL to the user, then confirm with From 138dd59bee3a117822a3b4770a15e86eb839c8a5 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 15:26:16 -0700 Subject: [PATCH 66/69] fix(daemon-cli): park teardown unwires elicitation; ensureDaemon waits out stopping daemons Two review findings from cliffhall's round 2 on the daemon lifecycle: Park teardown owns the unwire (F6): a parked call's expiry or cancel closed the elicitation channel without removing the call's subscriber, while the server-side call kept running. Its stale subscriber stayed first in line and could swallow a later call's elicitation. ParkedCall now carries the unwire handle and every teardown path (respond, expiry, cancel, cancelForConnection) detaches the subscriber immediately. ensureDaemon vs. shutting-down daemon (F9): ping now reports a stopping flag (and daemon status surfaces it, with a "(shutting down)" marker in human output). When ensureDaemon reaches a stopping daemon it waits for the old process to exit (pid-based, since the socket closes before the lock is released) and then spawns a fresh one, instead of surfacing a daemon_stopping failure to the user. Ping itself always succeeds; observing a shutdown never restarts the daemon. Adds regression tests for both: stale-subscriber swallow, registry unwire on expiry/cancelAll, stopping ping/status, waitForDaemonExit (dead pid, live-pid timeout, socket fallback), and a full ensureDaemon wait-then-respawn integration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- .../__tests__/daemon-coverage.test.ts | 83 +++++++++++++++++++ .../__tests__/daemon-elicitation-park.test.ts | 71 ++++++++++++++++ .../src/connection/format-connection.ts | 3 +- .../daemon-cli/src/daemon/elicitation-park.ts | 16 ++++ clients/daemon-cli/src/daemon/ensure.ts | 60 +++++++++++++- clients/daemon-cli/src/daemon/protocol.ts | 2 + clients/daemon-cli/src/daemon/server.ts | 7 +- 7 files changed, 238 insertions(+), 4 deletions(-) diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts index 9e370ec39e..1da5e31f05 100644 --- a/clients/daemon-cli/__tests__/daemon-coverage.test.ts +++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts @@ -18,7 +18,9 @@ import { ensureDaemon, readLogTail, resolveDaemonScriptPath, + waitForDaemonExit, } from "../src/daemon/ensure.js"; +import { spawn, spawnSync } from "node:child_process"; import { ConnectionRegistry } from "../src/daemon/connections.js"; import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; @@ -788,6 +790,87 @@ describe("daemon coverage", () => { await new Promise((r) => setTimeout(r, 150)); }); + it("ping and daemon/status report stopping during shutdown", async () => { + const d = freshDir(); + const srv = new DaemonServer({ dir: d, idleMs: 0 }); + await srv.start(); + const before = await srv.handle({ id: "p1", op: "ping" }); + expect(before).toMatchObject({ ok: true, result: { stopping: false } }); + await srv.stop("stop"); + // Ping never fails — a stopping (or just-stopped in-process) daemon + // still answers, flagged so ensureDaemon knows to wait it out. + const after = await srv.handle({ id: "p2", op: "ping" }); + expect(after).toMatchObject({ + ok: true, + result: { pong: true, stopping: true }, + }); + const status = await srv.handle({ id: "s1", op: "daemon/status" }); + expect(status).toMatchObject({ ok: true, result: { stopping: true } }); + }); + + it("waitForDaemonExit resolves for a dead pid and times out on a live one", async () => { + const dead = spawnSync(process.execPath, ["-e", ""]); + expect(dead.pid).toBeGreaterThan(0); + await waitForDaemonExit(dead.pid, "/nonexistent.sock", 2000, 10); + await expect( + waitForDaemonExit(process.pid, "/nonexistent.sock", 150, 25), + ).rejects.toMatchObject({ envelope: { code: "daemon_stopping" } }); + }); + + it("waitForDaemonExit falls back to socket reachability without a pid", async () => { + const d = freshDir(); + await waitForDaemonExit(undefined, path.join(d, "absent.sock"), 500, 10); + }); + + it("ensureDaemon waits out a stopping daemon and spawns a fresh one", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + // The "old daemon": a process that takes a moment to exit, and a socket + // that answers ping with stopping:true (as the real dispatch does). + const oldDaemon = spawn( + process.execPath, + ["-e", "setTimeout(()=>{},800)"], + { + stdio: "ignore", + }, + ); + const stoppingSocket = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ + id: req.id, + ok: true, + result: { pong: true, pid: oldDaemon.pid, stopping: true }, + }) + "\n", + ); + }); + }); + await new Promise((r) => stoppingSocket.listen(sock, r)); + // Mid-shutdown the socket goes away before the process does — the gap + // where spawning too early would die on the still-held lock. + setTimeout(() => { + stoppingSocket.close(); + try { + fs.unlinkSync(sock); + } catch { + // already gone + } + }, 200); + try { + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(true); + await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); + await new Promise((r) => setTimeout(r, 150)); + } finally { + oldDaemon.kill(); + } + }, 15000); + it("start-timeout error quotes the daemon's stderr log", async () => { const d = freshDir(); // A "daemon" that logs a failure and dies without ever binding a socket diff --git a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts index 6edaa0b46f..38b19d4c92 100644 --- a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts +++ b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts @@ -426,6 +426,49 @@ describe("daemon elicitation parking", () => { ); }); + it("expired park cannot swallow a new call's elicitation (stale subscriber unwired)", async () => { + server = new DaemonServer({ dir, idleMs: 0, elicitationTtlMs: 40 }); + const registry = server.registry as unknown as Record; + registry.connectionFor = () => ({ name: "srv", client }); + registry.liveClientFor = async () => client; + + // First call parks, then its park expires while the server-side call + // keeps running (the server never gives up) — so its outcome `finally` + // (the normal unwire point) has not fired. + const round1 = makeFormMessage("elicit-stale"); + const neverSettles = deferred(); + runMethodMock.impl = async () => { + emit(round1.message); + await neverSettles.promise; + return { kind: "result", result: {} }; + }; + const parked1 = await rpcCallTool("r1"); + expect((parked1 as { result: RpcResult }).result.kind).toBe( + "elicitation-pending", + ); + await round1.cancelled; // expiry fired + + // A new call in that window: its elicitation must reach ITS subscriber, + // not the expired park's closed channel (which would auto-cancel it). + const round2 = makeFormMessage("elicit-fresh"); + runMethodMock.impl = async () => { + emit(round2.message); + await round2.answered; + return { kind: "result", result: { ok: true } }; + }; + const parked2 = await rpcCallTool("r2"); + expect((parked2 as { result: RpcResult }).result.kind).toBe( + "elicitation-pending", + ); + expect(round2.message.cancel).not.toHaveBeenCalled(); + const done = await respond("r3", { + elicitationId: "elicit-fresh", + action: "accept", + content: { color: "red" }, + }); + expect(done.ok).toBe(true); + }); + it("disconnect cancels the parked call; respond then reports not found", async () => { const registry = server.registry as unknown as Record; registry.disconnect = async () => ({ name: "srv" }); @@ -556,6 +599,7 @@ describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => client, channel, outcome: new Promise(() => {}), + unwire: () => {}, }); expect(registry.forClient({} as InspectorClient)).toBeUndefined(); expect(registry.forClient(client)).toBeDefined(); @@ -570,6 +614,7 @@ describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => const registry = new ElicitationParkRegistry(0); const channel = new ParkingElicitationChannel(); const pending = channel.request(frame("e1")); + const unwire = vi.fn(); registry.add({ info: { elicitationId: "e1", @@ -582,9 +627,35 @@ describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => client: {} as InspectorClient, channel, outcome: new Promise(() => {}), + unwire, }); registry.cancelAll(); await expect(pending).rejects.toThrow(/going away/); expect(() => registry.take("e1")).toThrow(/No pending elicitation/); + // Cancel must also unwire the bridge subscriber of the abandoned call. + expect(unwire).toHaveBeenCalled(); + }); + + it("expiry unwires the bridge subscriber of the abandoned call", async () => { + const registry = new ElicitationParkRegistry(20); + const channel = new ParkingElicitationChannel(); + const pending = channel.request(frame("e2")); + const unwire = vi.fn(); + registry.add({ + info: { + elicitationId: "e2", + connection: "srv", + method: "tools/call", + mode: "form", + message: "hi", + origin: "server-request", + }, + client: {} as InspectorClient, + channel, + outcome: new Promise(() => {}), + unwire, + }); + await expect(pending).rejects.toThrow(/expired/); + expect(unwire).toHaveBeenCalled(); }); }); diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts index 3f8539ddc8..ea0bd08a44 100644 --- a/clients/daemon-cli/src/connection/format-connection.ts +++ b/clients/daemon-cli/src/connection/format-connection.ts @@ -300,7 +300,8 @@ function humanPayload(payload: ConnectionWriteKind, style: Style): string { ? (s.connections as unknown[]) : []; return [ - `${style.bold("Daemon")} pid ${String(s.pid)}`, + `${style.bold("Daemon")} pid ${String(s.pid)}` + + (s.stopping === true ? ` ${style.yellow("(shutting down)")}` : ""), style.dim(`Socket: ${String(s.socketPath ?? "")}`), formatConnectionsListHuman(connections, style), ].join("\n"); diff --git a/clients/daemon-cli/src/daemon/elicitation-park.ts b/clients/daemon-cli/src/daemon/elicitation-park.ts index f3d309df15..532d91158b 100644 --- a/clients/daemon-cli/src/daemon/elicitation-park.ts +++ b/clients/daemon-cli/src/daemon/elicitation-park.ts @@ -99,6 +99,16 @@ export type ParkedCall = { channel: ParkingElicitationChannel; /** Settles when the parked daemon-side call finishes (result or error). */ outcome: Promise; + /** + * Removes the call's bridge subscriber. Normally the call's own `finally` + * unwires when the underlying call settles — but a cancelled/expired park + * abandons a call that is still running server-side, and its subscriber + * would otherwise stay first in the bridge's dispatch order until the + * server settles it, swallowing (auto-cancelling) the next elicitation of + * any new call started in that window. Park teardown owns the unwire so a + * closed channel is never a dispatch target. Idempotent. + */ + unwire: () => void; /** * `awaiting` = parked, answerable; `responding` = an `elicitation/respond` * is in flight for it (a concurrent respond must not double-answer). @@ -135,6 +145,7 @@ export class ElicitationParkRegistry { client: InspectorClient; channel: ParkingElicitationChannel; outcome: Promise; + unwire: () => void; }): ParkedCall { const parked: ParkedCall = { ...entry, @@ -195,6 +206,10 @@ export class ElicitationParkRegistry { private cancel(entry: ParkedCall): void { this.finish(entry); + // The abandoned call may run server-side long after this park is gone; + // unwire its bridge subscriber now so it cannot shadow a new call's + // elicitations (see ParkedCall.unwire). + entry.unwire(); entry.channel.close( new CliExitCodeError( EXIT_CODES.UNREACHABLE, @@ -208,6 +223,7 @@ export class ElicitationParkRegistry { if (this.ttlMs <= 0) return; entry.timer = setTimeout(() => { this.finish(entry); + entry.unwire(); entry.channel.close( new CliExitCodeError( EXIT_CODES.UNREACHABLE, diff --git a/clients/daemon-cli/src/daemon/ensure.ts b/clients/daemon-cli/src/daemon/ensure.ts index a5bd827d84..19b723fbc4 100644 --- a/clients/daemon-cli/src/daemon/ensure.ts +++ b/clients/daemon-cli/src/daemon/ensure.ts @@ -130,6 +130,51 @@ export function readLogTail(logPath: string, maxLines = 10): string { } } +/** How long ensureDaemon waits for a stopping daemon to finish exiting. */ +export const STOPPING_EXIT_TIMEOUT_MS = 10_000; +const STOPPING_POLL_MS = 100; + +/** Signal-0 liveness probe; EPERM means alive but not ours. */ +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * Wait for a shutting-down daemon to actually exit. Keyed on the process + * (which holds the daemon lock until it dies), not the socket — the socket + * closes earlier in shutdown, and spawning in that gap would die on the + * still-held lock. Falls back to socket reachability when the ping predates + * the `pid` field. Exported for tests. + */ +export async function waitForDaemonExit( + pid: number | undefined, + socketPath: string, + timeoutMs = STOPPING_EXIT_TIMEOUT_MS, + pollMs = STOPPING_POLL_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const gone = + pid !== undefined + ? !pidAlive(pid) + : !(await isDaemonReachable(socketPath)); + if (gone) return; + if (Date.now() >= deadline) { + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + "Connection daemon is shutting down but did not exit in time; retry shortly.", + { code: "daemon_stopping" }, + ); + } + await new Promise((r) => setTimeout(r, pollMs)); + } +} + /** * Ensure a connection daemon is running for the current {@link getDaemonDir}. * Auto-spawns a detached Node process when the socket is not reachable. @@ -163,8 +208,19 @@ export async function ensureDaemon(options?: { // unreachable path below — is stale, and the spawned daemon itself // removes it after a connect probe (removeStaleDaemonSocket). token ??= readDaemonTokenFile(dir); - await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); - return { socketPath, spawned: false }; + const pong = await callDaemon<{ + pong: boolean; + pid?: number; + stopping?: boolean; + }>("ping", {}, { socketPath, timeoutMs: 2000, token }); + if (pong?.stopping !== true) { + return { socketPath, spawned: false }; + } + // The daemon answered but is shutting down; it would reject real work + // with daemon_stopping, and a respawn now would die on its still-held + // lock. To the caller the daemon is simply "up": wait for the old + // process to finish exiting, then fall through and spawn a fresh one. + await waitForDaemonExit(pong.pid, socketPath); } // Every daemon requires a token; generate one for the child when the diff --git a/clients/daemon-cli/src/daemon/protocol.ts b/clients/daemon-cli/src/daemon/protocol.ts index 1c211fa47b..a9f3df5286 100644 --- a/clients/daemon-cli/src/daemon/protocol.ts +++ b/clients/daemon-cli/src/daemon/protocol.ts @@ -300,6 +300,8 @@ export type DaemonStatus = { socketPath: string; connections: ConnectionInfo[]; idleMs: number | null; + /** True once shutdown has begun (status stays answerable while stopping). */ + stopping: boolean; }; /** Serializable RPC outcome (no live stream callbacks). */ diff --git a/clients/daemon-cli/src/daemon/server.ts b/clients/daemon-cli/src/daemon/server.ts index 5cc6937b79..a4900ec462 100644 --- a/clients/daemon-cli/src/daemon/server.ts +++ b/clients/daemon-cli/src/daemon/server.ts @@ -280,6 +280,7 @@ export class DaemonServer { socketPath: this.socketPath, connections: this.registry.list(), idleMs: this.registry.idleRemainingMs(), + stopping: this.stopping, }; } @@ -366,7 +367,10 @@ export class DaemonServer { response: { id: request.id, ok: true, - result: { pong: true, pid: process.pid }, + // `stopping` lets ensureDaemon treat a shutting-down daemon as + // "about to be gone" (wait for exit, respawn) instead of alive — + // ping itself always succeeds so status checks never fail. + result: { pong: true, pid: process.pid, stopping: this.stopping }, }, }; case "connect": { @@ -671,6 +675,7 @@ export class DaemonServer { client, channel, outcome, + unwire, info: pendingInfo(first.frame, connectionName, { method: params.method, toolName: params.toolName, From 3c3de07dbdf25fc078204f66c81ef42a6acfe66e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 15:32:58 -0700 Subject: [PATCH 67/69] feat(scripts): eval pool error containment + end-to-end mcpdo smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eval harness containment (F14, review round 2): a rejecting behavior sample used to reject the whole pool and exit the process while sibling samples' finally blocks (daemon stop, sandbox removal) were still pending — detached eval daemons genuinely leak that way. pool() now takes an optional onError mapper: the behavior section records an errored sample as a scored miss (failures note, zero calls) and keeps going. Without onError the pool stops taking new items, lets in-flight work finish its cleanup, then rethrows the first error. A throw from makeBehaviorEnv now also reclaims the sample's sandbox dir. Pool semantics pinned by unit tests. New smoke: `npm run smoke:mcpdo` (wired into `npm run smoke`, G1) drives the built daemon CLI end to end in a hermetic sandbox — catalog connect, tools/call, elicitation park + respond round-trip, daemon status, disconnect, daemon stop with verified process exit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- package.json | 3 +- scripts/skill-eval-mcpdo.mjs | 71 +++++++-- scripts/skill-eval-mcpdo.test.mjs | 54 +++++++ scripts/smoke-mcpdo.mjs | 232 ++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 scripts/smoke-mcpdo.mjs diff --git a/package.json b/package.json index 42dbd598ad..e3adb90b01 100644 --- a/package.json +++ b/package.json @@ -81,8 +81,9 @@ "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", - "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:chromium && npm run smoke:web:tabs", + "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:mcpdo && npm run smoke:tui && npm run smoke:web && npm run smoke:web:chromium && npm run smoke:web:tabs", "smoke:cli": "node scripts/smoke-cli.mjs", + "smoke:mcpdo": "node scripts/smoke-mcpdo.mjs", "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", "smoke:web:browser": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-browser.mjs", diff --git a/scripts/skill-eval-mcpdo.mjs b/scripts/skill-eval-mcpdo.mjs index f5608a7964..3dfc7ce73f 100644 --- a/scripts/skill-eval-mcpdo.mjs +++ b/scripts/skill-eval-mcpdo.mjs @@ -590,10 +590,16 @@ export const FAILURES_DIR = path.join( */ async function runBehaviorSample(c, agent) { const sandbox = makeSandbox(); - const { env, logPath, envDir, teardown } = await makeBehaviorEnv( - sandbox, - caseServers(c), - ); + let ready; + try { + ready = await makeBehaviorEnv(sandbox, caseServers(c)); + } catch (err) { + // makeBehaviorEnv cleans up its own envDir on failure; the sandbox + // predates it and is ours to reclaim. + rmSync(sandbox, { recursive: true, force: true }); + throw err; + } + const { env, logPath, envDir, teardown } = ready; const clicker = c.autoConsent === true ? startConsentClicker(logPath) : null; let keepEnvDir = false; let artifactsDir = null; @@ -649,17 +655,45 @@ async function runBehaviorSample(c, agent) { } } -async function pool(items, n, fn) { +/** + * Run `fn` over `items` with at most `n` in flight. + * + * A rejecting item must not blow up the pool mid-run: sibling samples own + * live resources (spawned daemons, sandbox dirs) that only their own + * try/finally reclaims, so an immediate `Promise.all` rejection would exit + * the process before that cleanup runs. With `onError`, a rejection is + * mapped to a result and the run continues. Without it, the pool stops + * taking new items, lets in-flight siblings finish (and clean up), then + * rethrows the first error. Exported for tests. + * + * @param {Array} items + * @param {number} n Max concurrency. + * @param {(item: T) => Promise} fn + * @param {(item: T, err: unknown) => R} [onError] + * @returns {Promise} + * @template T, R + */ +export async function pool(items, n, fn, onError) { const out = new Array(items.length); + let firstError = null; let i = 0; await Promise.all( Array.from({ length: Math.min(n, items.length) }, async () => { - while (i < items.length) { + while (firstError === null && i < items.length) { const idx = i++; - out[idx] = await fn(items[idx]); + try { + out[idx] = await fn(items[idx]); + } catch (err) { + if (onError) { + out[idx] = onError(items[idx], err); + } else { + firstError ??= err; + } + } } }), ); + if (firstError !== null) throw firstError; return out; } @@ -770,10 +804,25 @@ async function runBehaviorSection(cases, agent) { const samples = cases.flatMap((c) => Array.from({ length: BEHAVIOR_RUNS }, () => c), ); - const results = await pool(samples, CONCURRENCY, async (c) => ({ - c, - ...(await runBehaviorSample(c, agent)), - })); + const results = await pool( + samples, + CONCURRENCY, + async (c) => ({ + c, + ...(await runBehaviorSample(c, agent)), + }), + // Infrastructure failure (spawn error, fixture died), not a model miss — + // scored as a miss with an explicit reason so the section finishes and + // sibling samples' daemons/sandboxes still get their teardown. + (c, err) => ({ + c, + hit: false, + calls: 0, + failures: [`sample error: ${err?.message ?? String(err)}`], + transcript: [], + artifactsDir: null, + }), + ); let failed = 0; for (const c of cases) { const mine = results.filter((r) => r.c === c); diff --git a/scripts/skill-eval-mcpdo.test.mjs b/scripts/skill-eval-mcpdo.test.mjs index 8c89d17f1b..6bbd0e7988 100644 --- a/scripts/skill-eval-mcpdo.test.mjs +++ b/scripts/skill-eval-mcpdo.test.mjs @@ -24,6 +24,7 @@ import { readTranscript, startConsentClicker, loadCases, + pool, } from "./skill-eval-mcpdo.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -535,3 +536,56 @@ test( } }, ); + +test("pool: onError maps a rejecting item and every sibling still runs", async () => { + const done = []; + const results = await pool( + [1, 2, 3, 4, 5], + 2, + async (n) => { + if (n === 3) throw new Error(`boom-${n}`); + done.push(n); + return `ok-${n}`; + }, + (n, err) => `mapped-${n}:${err.message}`, + ); + assert.deepEqual(results, [ + "ok-1", + "ok-2", + "mapped-3:boom-3", + "ok-4", + "ok-5", + ]); + assert.deepEqual(done.sort(), [1, 2, 4, 5]); +}); + +test("pool: without onError it lets in-flight cleanup finish, stops new work, rethrows", async () => { + const cleaned = []; + const started = []; + let releaseSlow; + const slow = new Promise((r) => { + releaseSlow = r; + }); + await assert.rejects( + pool([1, 2, 3, 4], 2, async (n) => { + started.push(n); + try { + if (n === 1) { + // Rejects while item 2 is still in flight; its finally must run + // before the pool settles (that's where real samples tear down + // their daemons/sandboxes). + throw new Error("infra"); + } + await slow; + return n; + } finally { + cleaned.push(n); + if (n === 1) setTimeout(releaseSlow, 20); + } + }), + /infra/, + ); + // Item 2's cleanup ran; items 3 and 4 were never started after the abort. + assert.deepEqual(cleaned.sort(), [1, 2]); + assert.deepEqual(started.sort(), [1, 2]); +}); diff --git a/scripts/smoke-mcpdo.mjs b/scripts/smoke-mcpdo.mjs new file mode 100644 index 0000000000..b72be316d7 --- /dev/null +++ b/scripts/smoke-mcpdo.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * End-to-end smoke test for the experimental mcpdo daemon CLI + * (`clients/daemon-cli`). The unit/integration suite covers the daemon and + * command surface piecewise; this script drives the BUILT binary the way an + * agent shell would — non-TTY, catalog-based — and asserts the headline + * lifecycle end to end: + * + * 1. `connect ` resolves a catalog entry and spawns/uses the + * background daemon (auto-ensure path). + * 2. `@entry tools/call` round-trips a real stdio MCP server. + * 3. A tool that elicits (`submit_ticket`) parks: the CLI returns + * immediately with an `elicitation/respond ` handle instead of + * hanging (the skill's non-TTY contract). + * 4. `elicitation/respond field:=value ...` resumes the parked call + * and the original tool result comes back. + * 5. `daemon status --format json` reports the connection and + * `stopping: false`. + * 6. `disconnect` + `daemon stop` tear down, and the daemon process + * actually exits (socket file released). + * + * Fully hermetic: private daemon dir/socket, storage dir, catalog, and + * daemon token under a temp dir — the developer's real mcpdo daemon (if + * any) is untouched. Exits non-zero on any mismatch. + * + * Expects `clients/daemon-cli/build` to be built first (the validate / CI + * ordering guarantees this). The composed test server (`test-servers/build`) + * is rebuilt on every run — see `scripts/lib/ensure-test-servers.mjs`. + */ + +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { ensureTestServers } from "./lib/ensure-test-servers.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const mcpdoBin = join(repoRoot, "clients", "daemon-cli", "build", "mcp-bin.js"); +const serverLauncher = join( + repoRoot, + "scripts", + "lib", + "mcpdo-eval-server-launcher.mjs", +); + +function fail(message) { + console.error(`smoke:mcpdo FAILED — ${message}`); + process.exit(1); +} + +if (!existsSync(mcpdoBin)) { + fail(`missing build artifact ${mcpdoBin} — run \`npm run build\` first`); +} + +// Rebuilt on every run — presence is not freshness (#2111). The launcher +// imports `test-servers/build/index.js`, which the same tsc pass emits; +// `fixtures` is the named entry that pins the emit actually happened. +try { + ensureTestServers({ + repoRoot, + label: "smoke:mcpdo", + requires: ["fixtures"], + }); +} catch (e) { + fail(e.message); +} +const testServersIndex = join(repoRoot, "test-servers", "build", "index.js"); +if (!existsSync(testServersIndex)) { + fail(`test-servers build did not emit ${testServersIndex}`); +} + +// Hermetic sandbox: everything the daemon touches lives under here. +const sandbox = mkdtempSync(join(tmpdir(), "mcpdo-smoke-")); +const daemonDir = join(sandbox, "daemon"); +const storageDir = join(sandbox, "storage"); +mkdirSync(daemonDir); +mkdirSync(storageDir); + +// One composed stdio server: a plain tool (get_sum) for the basic call and +// an intrinsically-eliciting tool (submit_ticket) for the park round-trip. +const serverConfigPath = join(sandbox, "server-config.json"); +writeFileSync( + serverConfigPath, + JSON.stringify( + { + transport: { type: "stdio" }, + serverInfo: { name: "helpdesk", version: "1.0.0" }, + tools: [{ preset: "get_sum" }, { preset: "submit_ticket" }], + }, + null, + 2, + ), +); +const catalogPath = join(sandbox, "catalog.json"); +writeFileSync( + catalogPath, + JSON.stringify( + { + mcpServers: { + helpdesk: { + type: "stdio", + command: process.execPath, + args: [serverLauncher, serverConfigPath], + }, + }, + }, + null, + 2, + ), +); + +const SMOKE_ENV = { + MCP_INSPECTOR_DAEMON_DIR: daemonDir, + MCP_INSPECTOR_DAEMON_TOKEN: randomBytes(32).toString("base64url"), + MCP_STORAGE_DIR: storageDir, + MCP_CATALOG_PATH: catalogPath, + // Memory store keeps the smoke off the host keychain (no OAuth here, but + // the store is probed at startup) — mirrors smoke-cli.mjs. + MCP_INSPECTOR_SECRET_STORE: "memory", +}; + +/** Run one mcpdo invocation. Returns { status, stdout, stderr }. */ +function runMcpdo(args) { + const r = spawnSync(process.execPath, [mcpdoBin, ...args], { + cwd: repoRoot, + env: { ...process.env, ...SMOKE_ENV }, + encoding: "utf-8", + timeout: 60_000, + }); + if (r.error) fail(`mcpdo ${args.join(" ")} did not run: ${r.error.message}`); + return r; +} + +function step(name, args, { expectStatus = 0, match = [] } = {}) { + const r = runMcpdo(args); + if (r.status !== expectStatus) { + fail( + `${name}: expected exit ${expectStatus}, got ${r.status}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`, + ); + } + for (const m of match) { + if (!m.test(r.stdout)) { + fail(`${name}: stdout did not match ${m}\nstdout:\n${r.stdout}`); + } + } + console.log(`smoke:mcpdo ok — ${name}`); + return r; +} + +const sleep = (ms) => + new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); + +try { + // 1. Connect the catalog entry (auto-spawns the private daemon). + step("connect", ["connect", "helpdesk"], { + match: [/@helpdesk/], + }); + + // 2. Plain tool call round-trip. + step("tools/call", ["@helpdesk", "tools/call", "get_sum", "a:=2", "b:=3"], { + match: [/"result":\s*5/], + }); + + // 3. Eliciting tool parks instead of hanging; the CLI hands back a + // respond command with the elicitation id. + const parked = step( + "tools/call parks on elicitation", + ["@helpdesk", "tools/call", "submit_ticket", "summary:=Printer jammed"], + { match: [/Input required|elicitationPending/, /elicitation\/respond/] }, + ); + const idMatch = parked.stdout.match(/elicitation\/respond (\S+)/); + if (!idMatch) { + fail(`could not extract elicitation id from:\n${parked.stdout}`); + } + + // 4. Respond resumes the parked call; the tool's real result comes back. + step( + "elicitation/respond resumes the call", + [ + "elicitation/respond", + idMatch[1], + "contact_name:=Ada Lovelace", + "contact_email:=ada@example.com", + ], + { match: [/TCK-\d+/, /Ada Lovelace/] }, + ); + + // 5. Status sees the live connection and a non-stopping daemon. + const status = step("daemon status", [ + "--format", + "json", + "daemon", + "status", + ]); + const parsedStatus = JSON.parse(status.stdout); + if (parsedStatus.connections?.[0]?.name !== "helpdesk") { + fail(`daemon status missing helpdesk connection:\n${status.stdout}`); + } + if (parsedStatus.stopping !== false) { + fail(`daemon status should report stopping: false:\n${status.stdout}`); + } + + // 6. Teardown: disconnect, stop, and confirm the daemon really exits + // (socket removed once shutdown completes). + step("disconnect", ["disconnect", "helpdesk"]); + step("daemon stop", ["daemon", "stop"]); + const socketPath = join(daemonDir, "daemon.sock"); + const deadline = Date.now() + 10_000; + while (existsSync(socketPath)) { + if (Date.now() > deadline) + fail("daemon socket still present 10s after stop"); + await sleep(100); + } + console.log("smoke:mcpdo ok — daemon exited (socket released)"); + + console.log("smoke:mcpdo PASSED"); +} finally { + // Belt and braces: if a step failed mid-flight, don't leak the daemon. + spawnSync(process.execPath, [mcpdoBin, "daemon", "stop"], { + env: { ...process.env, ...SMOKE_ENV }, + encoding: "utf-8", + timeout: 30_000, + }); + rmSync(sandbox, { recursive: true, force: true }); +} From bee94efa3dd34de367b83d819fc469fa625c1562 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 17:39:03 -0700 Subject: [PATCH 68/69] =?UTF-8?q?fix(scripts,=20docs):=20Copilot=20round-3?= =?UTF-8?q?7=20findings=20=E2=80=94=20Windows=20env=20keys,=20stale=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agentEnv: base-allowlist match is now case-insensitive so mixed-case Windows keys (Path, SystemRoot, ComSpec, AppData) pass through with their original spelling; prefixes stay case-sensitive. Test added. - daemon-cli README: the docker isolation example now passes container flags after the documented `--` separator, so connect no longer parses them as its own options. - v2_cli_v2 spec: dropped the per-connection RPC mutex to-do (shipped as DaemonServer.rpcQueues) and rewrote the elicitation decision row to match shipped behavior (non-TTY/JSON callers park and return elicitationPending; nothing auto-declines). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- clients/daemon-cli/README.md | 2 +- scripts/skill-eval.mjs | 10 +++++++++- scripts/skill-eval.test.mjs | 17 +++++++++++++++++ specification/v2_cli_v2.md | 3 +-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md index 7bcba5a717..f8969e775e 100644 --- a/clients/daemon-cli/README.md +++ b/clients/daemon-cli/README.md @@ -108,7 +108,7 @@ wrap the stdio command in a container — this works today with no mcpdo support: ```bash -mcpdo connect docker run -i --rm --network none -v "$PWD:/work:ro" +mcpdo connect -- docker run -i --rm --network none -v "$PWD:/work:ro" ``` Tighten or loosen the flags per server (drop `--network none` if it needs diff --git a/scripts/skill-eval.mjs b/scripts/skill-eval.mjs index 2e78f33167..c16bfead4f 100755 --- a/scripts/skill-eval.mjs +++ b/scripts/skill-eval.mjs @@ -717,9 +717,17 @@ export function agentEnv(agent, source = process.env) { // set, and dropping them fails auth on those runs. ["ANTHROPIC_", "CLAUDE_", "XDG_", "AWS_", "GOOGLE_", "CLOUD_ML_"]; const env = {}; + // Windows environment keys keep mixed-case spellings (`Path`, `SystemRoot`, + // `ComSpec`), so the base-list match is case-insensitive; the original + // spelling is preserved in the returned env. Prefixes stay case-sensitive — + // the vendor vars they name are uppercase on every platform. + const baseUpper = base.map((k) => k.toUpperCase()); for (const key of Object.keys(source)) { if (source[key] === undefined) continue; - if (base.includes(key) || prefixes.some((p) => key.startsWith(p))) { + if ( + baseUpper.includes(key.toUpperCase()) || + prefixes.some((p) => key.startsWith(p)) + ) { env[key] = source[key]; } } diff --git a/scripts/skill-eval.test.mjs b/scripts/skill-eval.test.mjs index 76ad47ac6d..d41fab3113 100644 --- a/scripts/skill-eval.test.mjs +++ b/scripts/skill-eval.test.mjs @@ -1093,6 +1093,23 @@ test("agentEnv: agents get only process basics and their own credentials", () => assert.ok(!("GOOGLE_APPLICATION_CREDENTIALS" in copilot)); }); +test("agentEnv: mixed-case Windows base keys pass through with spelling intact", () => { + const source = { + Path: "C:\\Windows;C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + ComSpec: "C:\\Windows\\System32\\cmd.exe", + AppData: "C:\\Users\\dev\\AppData\\Roaming", + // Mixed case only helps base-list names; prefixes stay case-sensitive. + Anthropic_Api_Key: "not-a-real-prefix-match", + }; + const env = agentEnv("claude", source); + assert.equal(env.Path, source.Path); + assert.equal(env.SystemRoot, source.SystemRoot); + assert.equal(env.ComSpec, source.ComSpec); + assert.equal(env.AppData, source.AppData); + assert.ok(!("Anthropic_Api_Key" in env)); +}); + test("runPrompt: spawned agent env is minimal plus the caller's overlay", async () => { process.env.SKILL_EVAL_TEST_SECRET = "leak-me-not"; try { diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 83058d7cc2..01a309a55b 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -167,7 +167,6 @@ Both are wired into root `validate` / `coverage`. | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpdo` during `rpc`/`stream`. Connect-time only today. | | **Per-socket request serialization** | Requests on one connection are handled as lines arrive (single line capped at 1 MiB); safe while clients use one request per connection. | -| **Per-connection RPC mutex** | Parallel `mcpdo` processes against one connection can interleave on one `InspectorClient`. | | **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | | **Split `registerRpcCommands`** | Large Commander switch in `connection/mcp.ts`. | | **`mcpdo daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | @@ -175,7 +174,7 @@ Both are wired into root `validate` / `coverage`. | **Connection `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; connection authorize uses defaults / env only. | | **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | | **Stream fan-out / `mcpdo attach`** | One consumer per stream invocation today. | -| **Sampling CLI** | Still TUI/web. mcpdo handles server-driven _elicitation_ (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Decision: only `--format json` auto-declines elicitation; any other caller — including a non-TTY agent — is prompted and may answer form-mode questions on the user's behalf. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | +| **Sampling CLI** | Still TUI/web. mcpdo handles server-driven _elicitation_ (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. Behavior as shipped: on an interactive TTY, mcpdo prompts inline; any non-TTY or `--format json` caller gets the elicitation **parked** — the command returns immediately with an `elicitationPending` payload and the caller answers later via `elicitation/respond ` (or `--decline` / `--cancel`). Nothing auto-declines. URL mode never auto-accepts: completion is only confirmed by an explicit answer. | | **Ephemeral no-`connect` shortcuts on `mcpdo`** | Out of scope (keep two mental models). | | **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_CONNECTION=1`. | | **Human `--full` schema dumps** | Optional formatter polish. | From 205f6f49af1add96f32bc88a22d89bc9711cf26c Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 30 Sep 2026 17:56:30 -0700 Subject: [PATCH 69/69] docs: URL elicitation has no decline; AGENTS.md says connection CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round-38 advisories: skills/mcpdo/SKILL.md told agents to end a URL-mode elicitation with --decline, which the daemon rejects (--done or --cancel only); AGENTS.md still introduced mcpdo as the session CLI after the session→connection rename. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Dickinson --- AGENTS.md | 2 +- skills/mcpdo/SKILL.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd96814298..3b5e63ac35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Inspector V2 This is an application for inspecting MCP servers. It has four client -surfaces — Web, TUI, one-shot CLI, and the experimental session CLI (`mcpdo`) — +surfaces — Web, TUI, one-shot CLI, and the experimental connection CLI (`mcpdo`) — over a shared `core/`. **This file holds the _rules_: the conventions a reviewer cites against a diff.** diff --git a/skills/mcpdo/SKILL.md b/skills/mcpdo/SKILL.md index 720b3f6ab3..c96290fb21 100644 --- a/skills/mcpdo/SKILL.md +++ b/skills/mcpdo/SKILL.md @@ -112,7 +112,8 @@ more). - Answer with `mcpdo elicitation/respond field:=value ...` (repeat if the server asks again), or end it with `--decline` or `--cancel`. For URL-mode elicitations, relay the URL to the user, then confirm with - `elicitation/respond --done` (or `--decline`). The response returns the + `elicitation/respond --done` (or `--cancel`; URL mode has no decline). + The response returns the final tool result. - Parked calls expire after 10 minutes; one parked call per connection. Pass `--elicit off` on `connect` to have well-behaved servers fall back to their