diff --git a/AGENTS.md b/AGENTS.md index d05cc5a74d..e6a938946c 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 @@ -40,6 +41,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). Bundled +│ │ into the published package — 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) @@ -385,12 +389,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 1b813e3813..0fb8bcd0bf 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,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) — bundled into 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) @@ -63,7 +65,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/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/mcpi/README.md b/clients/mcpi/README.md new file mode 100644 index 0000000000..5a15038072 --- /dev/null +++ b/clients/mcpi/README.md @@ -0,0 +1,180 @@ +# MCP Inspector session CLI (`mcpi`) + +**Experimental** separate client, bundled into the published `@modelcontextprotocol/inspector` npm package alongside `mcp-inspector`. 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 + +```bash +npm install -g @modelcontextprotocol/inspector +mcpi --help +``` + +This installs both bins from the same package: `mcp-inspector` (web/one-shot CLI/TUI launcher) and `mcpi` (this session CLI). `mcpi`'s own `clients/mcpi` package is `"private": true` and is never published on its own — it ships only as a bundled build inside `@modelcontextprotocol/inspector`. + +## Build / run from this repo (development) + +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/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 04728a5896..270b8ca249 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, @@ -15,6 +16,7 @@ import { } from "./types.js"; import type { Root } from "@modelcontextprotocol/client"; import type { + ElicitCapabilityMode, InspectorServerSettings, RequestMetadata, MCPConfig, @@ -47,6 +49,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 @@ -152,6 +176,7 @@ type StoredInspectorFields = Pick< | "headers" | "metadata" | "protocolEra" + | "elicitCapability" | "modernLogLevel" | "connectionTimeout" | "requestTimeout" @@ -524,6 +549,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; @@ -564,6 +590,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. @@ -718,6 +750,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. @@ -807,6 +850,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 87dc7dbf9c..45e1c25fef 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 @@ -687,6 +694,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 @@ -915,6 +931,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 diff --git a/package.json b/package.json index d151b29804..b480bb4bbd 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", + "mcpi": "./clients/mcpi/build/mcp-bin.js" }, "files": [ "clients/launcher/build", @@ -26,6 +27,8 @@ "clients/web/dist", "clients/web/static", "clients/cli/build", + "clients/mcpi/build", + "skills/mcpi", "clients/tui/build", "scripts/install-clients.mjs" ], @@ -33,14 +36,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 +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: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 +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 ../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. |